---
title: "RBAC and ABAC in Practice: Building Permission Systems with CASL"
description: "Build a permission system with CASL: roles to rules, ownership and tenant conditions, field-level access, deny-rule ordering, database filters, and testing."
canonical: "https://www.suriyaprakash.in/blog/engineering/rbac-abac-casl-permissions"
category: "Engineering"
date: "2026-08-29"
updated: "2026-08-29"
series: "Production Backend Systems"
tags:
  - authorization
  - rbac
  - abac
  - casl
  - permissions
  - multi-tenancy
  - security
  - backend
---

# RBAC and ABAC in Practice: Building Permission Systems with CASL

Canonical: https://www.suriyaprakash.in/blog/engineering/rbac-abac-casl-permissions

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.

```txt
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:

```txt
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`:

```ts
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.

```ts
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:

```ts
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:

```ts
// 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

```ts
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:

```ts
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

```ts
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:

```ts
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:

```ts
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:

```ts
// @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:

```txt
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).
- **`null` means nothing is permitted, so return an empty result without querying.** Treating `null` as "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:

```sql
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:

```ts
// 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.

```ts
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

```txt
 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.
