Field note / Engineering+
RBAC and ABAC in Practice: Building Permission Systems with CASL
Build a permission system with CASL: roles to rules, ownership and tenant conditions, field-level access, deny-rule ordering, database filters, and testing.
Part 1 explained the difference between RBAC and ABAC. Parts 2 to 4 covered how a request becomes authenticated: sessions, tokens, federated login, and passwords with a second factor. By now the application knows who is calling.
This part is about the decision that follows, and where it most often goes wrong. "Can this person do this to that record?" is easy to answer in one handler and very hard to answer consistently across fifty. The usual result is a growing pile of if (user.role === 'admin' || post.authorId === user.id) checks that disagree with each other.
CASL is a small JavaScript and TypeScript library for keeping that decision in one place. This post uses it to show a design that applies to any authorization layer: describe permissions as data, evaluate them in one function, and let the same rules filter database queries.
Everything below was run against both @casl/ability 6.8.1 and 7.0.2 (the current release when I wrote this). The rule behavior was identical in both; the one API difference, for turning rules into a database filter, is called out where it appears. Where I describe behavior, I tested it; where something is a design recommendation, I say so. The examples use a made-up multi-tenant document system.
Quick answer
- RBAC answers "what kind of user is this?" A role is a named bundle of permissions. It is easy to explain, audit and assign.
- ABAC answers "what is true about this user, this record and this situation?" Ownership, tenant, status and time are attributes. They express rules a role cannot, like "edit your own drafts".
- Real systems use both. Roles decide the broad shape; conditions on attributes narrow it per record.
- CASL expresses each permission as a rule: an action, a subject type, optional conditions on the record, and optional fields. You build a list of rules per user, then ask
ability.can(action, subject). - Four behaviors catch people out: later rules override earlier ones, a type-level check ignores conditions, a plain object without a type never matches, and a field-restricted rule still passes a check that names no field.
- Never rely on the check alone for lists. Turn the same rules into a database filter, so unauthorized rows are never loaded.
user + role + tenant
|
v
build rules (per request) -> [ can/cannot rules as data ]
| |
| +---------+----------+
v v v
ability.can(...) rules -> DB filter fields allowed
single record list endpoints response shaping
RBAC and ABAC, briefly
You met both in Part 1, so here is only the part that matters for implementation.
RBAC attaches permissions to roles and roles to users. Its strength is that a human can read it: "editors can publish". Its weakness is role explosion. The moment a rule needs context ("editors can publish, but only in their own department"), you start minting roles such as editor-sales and editor-support, and the role list stops being readable.
ABAC decides from attributes of the subject (user), the resource (record), the action and the environment. "Users can edit documents they own, while the document is a draft" needs no new role; it is a rule over ownerId and status.
The practical pattern is hybrid: roles pick a base set of rules, and each rule can carry conditions over attributes. CASL's rule model is built for exactly that.
How CASL models a permission
A rule has up to four parts:
action what is being done 'read', 'update', 'publish'
subject what it is done to 'Document'
conditions which records it applies to { tenantId: 't1', ownerId: 42 }
fields which fields it covers ['title', 'body']
You declare rules with an AbilityBuilder:
import { AbilityBuilder, createMongoAbility } from '@casl/ability'
export function defineAbilityFor(user: User) {
const { can, cannot, build } = new AbilityBuilder(createMongoAbility)
can('read', 'Document', { tenantId: user.tenantId })
can('update', 'Document', { tenantId: user.tenantId, ownerId: user.id })
cannot('delete', 'Document', { status: 'locked' })
return build()
}
Conditions use a MongoDB-style query language, but nothing here needs MongoDB. The matching runs in plain JavaScript against the object you give it. I verified that $in, $gte, $lt and dotted paths such as 'owner.id' all match as expected.
Two vocabulary notes. 'manage' is a wildcard action that covers every action, including custom ones (I checked that can('manage', 'Post') allows a made-up publish). 'all' is a wildcard subject. Together, can('manage', 'all') allows anything, which is how a super-admin is usually expressed. Use it sparingly, for the reasons in Part 1's warning about admin as a universal bypass.
Rules are plain data. After building an ability, ability.rules is a JSON-serializable array, and createMongoAbility(rules) rebuilds an equivalent ability from it. I tested the round trip: an ability rebuilt from its JSON rules still allowed its own tenant's record and rejected another tenant's. That property is what makes everything in the "roles as data" section below possible.
Turning roles into rules
Keep the role-to-permission mapping in one function, and make the outputs of that function the only way permissions come into existence.
type Role = 'viewer' | 'editor' | 'admin'
const roleRules: Record<Role, (u: User, b: AbilityBuilder<AppAbility>) => void> = {
viewer(u, { can }) {
can('read', 'Document', { tenantId: u.tenantId })
},
editor(u, { can, cannot }) {
can('read', 'Document', { tenantId: u.tenantId })
can('create', 'Document', { tenantId: u.tenantId })
can('update', 'Document', { tenantId: u.tenantId, ownerId: u.id })
cannot('update', 'Document', { status: 'archived' })
},
admin(u, { can }) {
can('manage', 'Document', { tenantId: u.tenantId })
can('manage', 'User', { tenantId: u.tenantId })
},
}
export function defineAbilityFor(user: User) {
const builder = new AbilityBuilder<AppAbility>(createMongoAbility)
for (const role of user.roles) roleRules[role](user, builder)
return builder.build()
}
(User and AppAbility stand for your own types.) One consequence of the ordering rule is already hiding in this sketch: a user with both the editor and admin roles gets the editor's cannot first and the admin's broader can after it, so the deny is overridden. If denies must hold across roles, collect allow rules and deny rules separately and always declare every allow before any deny.
Notice what the admin rule does not do: it does not say can('manage', 'all'). Even the most privileged role here is scoped to the user's own tenant. That is a deliberate design: tenant isolation is not a feature of a particular role; it is a condition present on every rule that touches tenant data.
Make tenant isolation structural
Part 1 listed "wrong tenant" among the most damaging authorization bugs. In a shared-database multi-tenant system it is easy to forget the condition on one rule. A way to make forgetting harder is to route all rule creation for tenant-owned subjects through a helper that always adds the tenant condition:
function tenantScoped(user: User, b: AbilityBuilder<AppAbility>) {
return {
can: (action: string, subject: string, extra: object = {}) =>
b.can(action, subject, { tenantId: user.tenantId, ...extra }),
cannot: (action: string, subject: string, extra: object = {}) =>
b.cannot(action, subject, { tenantId: user.tenantId, ...extra }),
}
}
This is a recommendation, not a CASL feature. It does not replace tests (see below), but it moves the safe behavior from "remember to" to "the default".
The four behaviors that catch people out
I tested each of these directly, because they are easy to get wrong from memory.
1. Later rules override earlier rules
The order you declare rules matters, and the last matching rule wins:
// A: broad allow, then a specific deny -> the deny works
can('read', 'Post'); cannot('read', 'Post', { secret: true })
ability.can('read', subject('Post', { secret: true })) // false
// B: the same two rules in the opposite order -> the deny is silently overridden
cannot('read', 'Post', { secret: true }); can('read', 'Post')
ability.can('read', subject('Post', { secret: true })) // true
In both cases the rules are identical; only the order differs, and the answer flips. The convention that avoids this: declare broad can rules first, then specific cannot rules. When you build rules from several roles, ordering across roles matters too, so decide it deliberately (for example, apply all allow rules, then all deny rules).
A related point: a cannot with no conditions after a conditional can removes the access entirely. I tested that combination and converting it to a database filter gave null, meaning no row can match.
2. A type-level check ignores conditions
can('update', 'Post', { authorId: 1 })
ability.can('update', 'Post') // true
ability.can('update', subject('Post', { authorId: 1 })) // true
ability.can('update', subject('Post', { authorId: 2 })) // false
Asking can('update', 'Post') means "is there any post this user could update?", so it returns true here. That is useful for deciding whether to show an "Edit" menu or an "Add" button, and dangerous as the only guard on a specific record. Authorize an instance, not a type, whenever a particular record is involved.
3. A plain object has no type, so it matches nothing
CASL decides a record's type from a brand it reads off the object, or from its class. A bare object literal has neither, so rules written for 'Post' do not apply to it. I tested this: can('update', { authorId: 1 }) returned false even though a rule for that author existed.
The fix is the subject() helper, which adds a hidden __caslSubjectType__ property:
import { subject } from '@casl/ability'
ability.can('update', subject('Post', row))
This fails closed, which is the safe direction, but it fails silently, so a forgotten subject() shows up as "nobody can edit anything" rather than an error. Wrap it in your repository layer so rows come back already branded, or enforce it with TypeScript types.
4. Field rules still pass a field-less check
can('update', 'Post', ['title', 'body'], { authorId: 1 })
const mine = subject('Post', { authorId: 1 })
ability.can('update', mine) // true (some field is allowed)
ability.can('update', mine, 'title') // true
ability.can('update', mine, 'status') // false
A check that names no field returns true as soon as any field is permitted. If your update endpoint accepts a JSON body, check each submitted key, or compute the allowed set once and reject anything outside it:
import { permittedFieldsOf } from '@casl/ability/extra'
const allowed = permittedFieldsOf(ability, 'update', mine, {
fieldsFrom: (rule) => rule.fields ?? [],
}) // ['title', 'body']
const rejected = Object.keys(body).filter((k) => !allowed.includes(k))
if (rejected.length) throw new HttpError(403, `Cannot change: ${rejected.join(', ')}`) // your own error type
Field-level rules matter beyond writes. The same list can drive response shaping: strip fields the caller may not read before serializing, so a "read" permission on a record does not automatically expose every column.
Enforce on the server, and throw one kind of error
CASL gives you a ForbiddenError helper that turns a failed check into an exception:
import { ForbiddenError } from '@casl/ability'
ForbiddenError.from(ability).throwUnlessCan('delete', subject('Post', row))
// Error: Cannot execute "delete" on "Post"
That message is a developer message. Map the exception to your own HTTP response at one place. A decision worth making deliberately: when a user is not allowed to see a record at all, many APIs return 404 rather than 403, so the response does not confirm the record exists. When they can see it but may not change it, 403 is accurate. Part 1's rule still applies in full: the frontend may use abilities to hide buttons, but the backend is where enforcement happens, because anything the browser holds can be edited.
Lists are where authorization actually leaks
Checking a single record is the easy case. The dangerous endpoint is the list: "all documents". If you load every row and then filter in application code, you have already read data the caller may not see, you will paginate wrong (a page of 20 may contain 3 allowed rows), and a missed filter exposes everything.
The better pattern is to translate the rules into a database filter, so the query itself can only return permitted rows. CASL's rulesToCondition (called rulesToQuery in version 6) does this. Here is what it produced in my tests:
// @casl/ability 7.x
import { rulesToCondition } from '@casl/ability/extra'
const toCondition = (r) => (r.inverted ? { $not: r.conditions } : r.conditions)
const hooks = {
and: (conditions) => ({ $and: conditions }),
or: (conditions) => ({ $or: conditions }),
empty: () => ({}),
}
const filterFor = (ability, action, type) =>
rulesToCondition(ability.rulesFor(action, type), toCondition, hooks)
In 6.x the same job is one call, rulesToQuery(ability, action, type, toCondition), imported from the same path. In 7.x that function is gone, replaced by rulesToCondition (shown above) and rulesToAST, which returns a parsed condition tree instead of a plain object. These are the results I got from both versions for the same rules:
can('read','Post',{published:true}); can('read','Post',{authorId:7})
-> { $or: [ { authorId: 7 }, { published: true } ] }
can('read','Post'); cannot('read','Post',{secret:true})
-> { $and: [ { $not: { secret: true } } ] }
cannot('read','Post',{secret:true}); can('read','Post') (deny declared first)
-> {} the later can overrides the deny, as in the check above
can('read','Post') -> {} (no restriction)
can(conditional) then a blanket cannot -> null (nothing is permitted)
no rules at all -> null (nothing is permitted)
Note that the filter follows the same ordering rule as the checks: it is built from the rules in priority order, so a mis-ordered deny weakens the query exactly as it weakens can. Read those results carefully, because each needs different handling:
- An object means "add this to the WHERE clause".
{}means no restriction from authorization (but your tenant scope should still be in the rules).nullmeans nothing is permitted, so return an empty result without querying. Treatingnullas "no filter" would turn "no access" into "all access", which is exactly the failure this pattern exists to prevent.
You still have to translate the Mongo-style object into your query layer's syntax. For Mongoose and Prisma, CASL has companion packages for that (@casl/mongoose, @casl/prisma), whose current versions list @casl/ability 7 as a peer dependency. For a SQL builder or an ORM like Drizzle, write a small, tested translator for the operators you actually use, and fail closed on any operator you do not support rather than ignoring it. In the same spirit, note what CASL itself does with an operator it does not know: in both versions I tested, a rule with an unknown operator such as $bogus simply never matched (the check returned false). That fails closed, but silently, so validate stored rules (see below) instead of trusting a typo to behave.
Combining this with the tenant condition from earlier means the query is always shaped like:
SELECT * FROM documents
WHERE tenant_id = :tenant -- from the rules, not from the request
AND (status <> 'archived') -- from a deny rule
AND (owner_id = :user OR published = true) -- from the allow rules
LIMIT 20 OFFSET 0;
Roles as data: storing permissions in a database
Hard-coded role functions are the simplest design and the right place to start. When customers need to define their own roles, rules have to be data. CASL helps because rules are plain JSON, as shown earlier.
A practical shape: store each role as a list of rules in a table, with a placeholder for values that depend on the current user, and resolve placeholders on the server when you build the ability:
// stored in the database
const stored = [
{ action: 'update', subject: 'Document', conditions: { ownerId: '${user.id}' } },
]
// resolved per request, on the server
const rules = JSON.parse(JSON.stringify(stored), (_k, v) =>
v === '${user.id}' ? user.id : v,
)
const ability = createMongoAbility(rules)
I tested exactly this: the resolved ability allowed the user's own record and rejected another user's. A few cautions that come with moving rules into a database:
- Validate rules on write. Because an unknown operator silently never matches, a typo in a stored rule will not raise an error; it will quietly grant or deny nothing. Check stored rules against an allow-list of actions, subjects, fields and operators when an administrator saves them.
- Never resolve placeholders from request input. Values like the user id and tenant id come from the authenticated session (Part 2), not from a header or body field the caller controls.
- Treat permission changes as staleness events. Part 2 covered how long-lived credentials carry stale claims. If you cache a user's rules, version them, so a role change takes effect on the next request rather than at the next login.
- Keep the check logic out of the rules table. Rules describe what is allowed. Business workflow ("an invoice must be approved before payment") belongs in your domain code.
CASL also offers packRules and unpackRules (in @casl/ability/extra), which compress rules into short arrays for sending over a network. In my test one conditional rule packed to [["read","Post",{"tenantId":"t1"}]]. If you send rules to the browser so the UI can hide controls, that is a convenience for the interface, never a substitute for server checks.
Performance and where to build the ability
Build the ability once per request, not per check. Construction processes every rule, and a handler may check many records. Attach it to the request context and reuse it. If rules come from a database, cache them per user (with a version) rather than reading the table on every request.
Testing a permission system
Permissions fail in the cells nobody thought about, so test as a matrix: roles on one axis, actions and record states on the other, with an explicit expected answer for each.
const cases = [
// role, action, record, expected
['viewer', 'update', { tenantId: 't1', ownerId: 1 }, false],
['editor', 'update', { tenantId: 't1', ownerId: 1 }, true],
['editor', 'update', { tenantId: 't1', ownerId: 2 }, false],
['editor', 'update', { tenantId: 't2', ownerId: 1 }, false], // other tenant
['editor', 'update', { tenantId: 't1', ownerId: 1, status: 'archived' }, false],
['admin', 'delete', { tenantId: 't2' }, false], // admin is still tenant-scoped
]
Add tests that specifically target the failure modes above: a deny rule declared in the wrong order, a missing tenant condition, a forgotten subject() brand, and the list filter returning null for a user with no rules. These are cheap to write and are exactly the bugs that reach production.
Common mistakes
| Mistake | What it causes |
|---|---|
cannot declared before a broader can | The deny is silently overridden |
Using can('update', 'Post') to guard a specific record | Passes if the user can update any post |
Passing a plain row to can | Nothing matches; everything is denied with no error |
Checking a field-less update on a body with many fields | Allows editing fields the rule never covered |
| Loading all rows, then filtering in code | Over-fetching, wrong pagination, leak risk |
Treating null from a rule-to-query conversion as "no filter" | Turns "no access" into "all access" |
| Tenant condition added by hand on each rule | One forgotten rule exposes another tenant |
Resolving ${user.id} from request input | Caller chooses their own permissions |
can('manage', 'all') for convenience | One compromised account has everything |
Interview answer vs production answer
The interview answer: "RBAC uses roles, ABAC uses attributes, and I'd combine them with a library like CASL."
The production answer adds the parts that decide whether the system is actually safe:
- Where the decision lives matters more than the model. One function that builds rules, one place that enforces them, and one error mapping beats a clever model scattered across handlers.
- Deny rules are ordering-sensitive. State the ordering convention and test it.
- Lists need filters, not post-checks. Convert rules to a query, fail closed on anything you cannot translate, and handle the "no access" case explicitly.
- Tenant isolation should be structural. It should be the default, not something each rule remembers.
- Permissions are data with a lifecycle. They need validation, versioning and a plan for staleness.
- The UI is a hint. Hiding a button is a usability decision; the server check is the security decision.
FAQ
Do I need CASL, or can I write my own checks?
For a handful of rules, plain functions are fine. CASL earns its place when you want rules as data, per-record conditions, field-level access and a way to reuse the same rules for database filtering. The concepts in this post apply with or without it.
Should permissions be in the JWT?
Be careful. A token carries a snapshot, and Part 2 covered why that goes stale. A common approach is to put the user and tenant identifiers in the token or session, and build the rules on the server from current data.
What is the difference between can('manage', 'all') and an admin role?
manage and all are wildcards for action and subject. An admin role is a business concept that may or may not use them. Prefer scoping even admins to a tenant, and reserve a global wildcard for a tightly controlled platform-operator role.
Can I check permissions on the client?
Yes, for the interface: hiding controls and routes the user cannot use. CASL ships React bindings for this. Never treat a client-side result as enforcement.
How do I handle "users can only see their own data" in a list?
Add ownerId: user.id as a condition and convert the rules to a database filter, so the query itself returns only those rows. Do not load everything and filter afterward.
Why did my permission check return false with no error?
Common causes: the record was passed as a plain object without subject(), the action or subject name has a typo, an earlier can was overridden by a later cannot, or the conditions do not match the object's field names or types.
How should I log authorization decisions?
Log denials for sensitive actions with the user, action, subject type and record id, and keep audit records for the allowed sensitive actions too. That is the "no audit logs" row in Part 1's mistakes table, and it pays off in incident investigation.
Is ABAC always better than RBAC?
No. Attributes are more expressive and also harder to reason about and audit. Start with roles, and add conditions only where roles alone force an explosion of near-duplicate roles.
Glossary
| Term | Simple meaning |
|---|---|
| RBAC | Permissions granted through roles |
| ABAC | Decisions based on attributes of the user, record, action and context |
| Ability | CASL's object that answers can / cannot |
| Rule | One permission: action, subject, optional conditions and fields |
| Subject | The kind of thing being acted on ('Document') or an instance of it |
| Condition | A query over the record's attributes that limits a rule |
| Inverted rule | A cannot rule that removes access |
subject() | Helper that tags a plain object with its subject type |
| Field-level permission | A rule limited to certain fields of a record |
rulesToCondition | Converts rules into one query condition for the database (rulesToQuery in 6.x) |
| Tenant | One customer's isolated slice of a shared system |
| Role explosion | The growth of near-duplicate roles to encode context |
The end-to-end mental model
authenticated request (Parts 2-4)
|
v
user: id, tenant, roles
|
v
build rules (roles -> can/cannot, tenant condition on every rule)
|
+--------+-----------------------------+
| |
v v
one record a list
ForbiddenError.throwUnlessCan rules -> condition
(subject(), per-field check) { ... } -> WHERE clause
null -> return empty, no query
| |
+------------------+-------------------+
v
response shaped by permitted fields
|
v
audit log for sensitive actions
Authentication told us who is at the door. This post is the rulebook that decides which rooms they may enter, and the rulebook only works if every room is checked by the same rules. The permission system is itself an API surface, though, and it sits behind endpoints that anyone on the internet can send requests to. That is the next step in the series: API security, covering rate limiting, CORS, CSRF, input validation and security headers.
Series / Production Backend Systems
Continue the sequence.
Part 5 / 10