What you'll learn
Quick Answer
RBAC (role-based access control) attaches permissions to named roles likeeditororadmin, then assigns roles to users. ABAC (attribute-based access control) makes each decision by evaluating rules against attributes - who the user is, which resource they are touching, and the context such as time or ownership. RBAC is simpler to audit and show in a UI; ABAC expresses fine-grained rules like "only your own records" that RBAC cannot. Most production systems use RBAC for coarse access plus a few attribute checks.
The Core Difference
RBAC: a permission is a static fact about a role. You define once that editor grants article:update, give Asha the editor role, and the check becomes a lookup - does any of the user's roles grant this permission? The decision needs only the user.
ABAC: a permission is computed per request from attributes. Nothing stores "Asha can update article 7." Instead a rule says "a user may update an article if they own it and it is not locked," and that rule is evaluated with Asha and article 7 in hand, every time. The decision needs the user, the specific resource, the action, and often the environment.
One consequence shows up immediately. RBAC answers "what can this user do?" cheaply - list the permissions of their roles. ABAC answers "can this user do this exact thing right now?" but struggles with the general question, because the answer depends on resources that may not even exist yet.
RBAC in Code
const roleGrants = {
viewer: ["article:read"],
editor: ["article:read", "article:update", "article:create"],
admin: ["article:read", "article:update", "article:create",
"article:delete", "user:manage"],
};
function can(user, permission) {
return user.roles.some(role => (roleGrants[role] || []).includes(permission));
}Verified output: an editor passes article:update (true) and fails article:delete (false); an admin passes user:manage (true). A user can hold several roles, and the check passes if any of them grants the permission, so roles add up rather than conflict.
In a real system the roleGrants table and the user-to-role assignments live in the database, and the rest is this one lookup. That simplicity is the point. It maps cleanly to a UI - a grid of checkboxes, one row per role. It is easy to audit: "who can delete articles?" is answered by "anyone with the admin role," with no code to trace. And roles line up with how organizations already describe people - support agent, billing admin, read-only auditor. Keep the permissions themselves as granular verbs like article:update, not screen names like canSeeAdminPanel; screens get redesigned, the underlying capability does not.
Where RBAC Breaks Down
The check above takes (user, permission). It never sees the article. So "editors may update their own drafts but not other people's" cannot be expressed: can(asha, "article:update") is true for every article or none. Verified - there is no argument to scope it to an owner.
Teams work around this by minting more roles: editor-marketing, then editor-marketing-us, then editor-marketing-us-contractor. Every new dimension - department, region, employment type, resource state - multiplies the role count. This is role explosion, and it ends in hundreds of near-identical roles nobody can audit, which destroys the one advantage RBAC had.
The tell that you have outgrown pure RBAC: your role names contain conditions (...-readonly, ...-own), or your permission checks start taking the resource as a second argument and branching on resource.ownerId === user.id. That branch is an attribute rule. You are already doing ABAC, just informally and without a name for it.
ABAC in Code
const policies = [
(s, a, r) => r.ownerId === s.id ? "PERMIT" : null,
(s, a, r) => a === "read" && r.department === s.department ? "PERMIT" : null,
(s, a, r, e) => a === "update" && (e.hour < 9 || e.hour >= 18) ? "DENY" : null,
];
function can(subject, action, resource, env) {
let decision = "DENY"; // default deny
for (const p of policies) {
const result = p(subject, action, resource, env);
if (result === "DENY") return false; // explicit deny wins
if (result === "PERMIT") decision = "PERMIT";
}
return decision === "PERMIT";
}Verified: an editor updating their own article at 11:00 is allowed; the same edit at 22:00 is denied by the time policy; reading a colleague's article in the same department is allowed; a different department is denied. Notice the request now needs the resource and the environment, not just the user - so before this runs you have to load the article and read the clock, on every check.
Two design rules carry the weight. Default deny - if no policy returns PERMIT, the answer is no, so a forgotten rule fails closed. Deny overrides - one DENY beats any number of PERMITs, so "no edits after hours" cannot be undone by "owners can edit." Get that combining logic wrong and you have a hole that passes every happy-path test. The cost of this flexibility is that "who can edit this article?" no longer has a static answer, and the policies themselves now need unit tests the way application code does.
What to Actually Build
Start with RBAC. It covers most real requirements and everyone understands it. Then layer attribute checks on top only where you actually need them:
function can(user, action, resource) {
const roleAllows = user.roles.some(r => grants[r]?.includes("doc:" + action));
if (!roleAllows) return false;
if (user.tenantId !== resource.tenantId) return false; // isolation
if (action === "update" && !user.roles.includes("admin")
&& resource.ownerId !== user.id) return false; // own-only
return true;
}Verified: a member edits their own doc but not a colleague's, everyone reads within their tenant, an admin edits any doc in their tenant but nothing in another. The role check does the coarse filtering; two small attribute checks handle the rest. This hybrid is what most SaaS ships, and it stays readable because there are only a handful of attribute rules, not hundreds.
Reach for a real policy engine - Open Policy Agent, AWS Cedar, Oso - when the rules grow numerous enough to need their own tests and review rather than if statements scattered across controllers, or when non-engineers need to read and change them. The gotcha that bites hardest: doing the role check and forgetting the ownership or tenant check, so the code passes review and every editor can edit every organization's data. Put the tenant check in shared middleware so it cannot be left out.
