Role-Based Access Control (RBAC)

RBAC is a common pattern: users have roles (admin, editor, viewer), roles have permissions (delete:post, edit:post, view:post). To check if a user can perform an action, look up their role and check if the role has that permission. Simple, scalable, and works for most apps.

Intermediate

4 min read

What is RBAC?

RBAC is a way to organize permissions into roles and assign roles to users.

User: alice
  Roles: [Editor]
    Permissions: [read:post, create:post, edit:own_post, delete:own_post]

User: bob
  Roles: [Viewer]
    Permissions: [read:post]

User: charlie
  Roles: [Admin]
    Permissions: [read:post, create:post, edit:any_post, delete:any_post, manage_users]

When alice tries to delete a post, you check:

  1. Is alice authenticated? Yes.
  2. What roles does alice have? [Editor].
  3. Does [Editor] have permission delete:post? No (only delete:own_post).
  4. Is alice the owner of this post? Yes.
  5. Does [Editor] have permission delete:own_post? Yes.
  6. Allow the delete.

RBAC data model

class User:
    id: str
    email: str
    roles: list[str]  # ["editor", "viewer"]
 
class Role:
    id: str
    name: str
    permissions: list[str]  # ["read:post", "edit:post"]
 
# In your database:
users table: id, email, hashed_password, created_at
user_roles table: user_id, role_id
roles table: id, name
role_permissions table: role_id, permission_id
permissions table: id, name, description

Or simplified (for small apps):

class User:
    id: str
    email: str
    role: str  # "admin" | "editor" | "viewer"
 
def get_permissions(role: str) -> list[str]:
    return {
        "admin": ["read:post", "create:post", "edit:post", "delete:post"],
        "editor": ["read:post", "create:post", "edit:own_post", "delete:own_post"],
        "viewer": ["read:post"],
    }.get(role, [])

Authorization check at action time

When a user tries to perform an action, check their role/permissions:

def can_delete_post(user: User, post_id: str) -> bool:
    post = db.get_post(post_id)
    if not post:
        return False  # post doesn't exist
    
    # Get user's permissions
    permissions = get_permissions(user.role)
    
    # Check "delete:post"
    if "delete:post" in permissions:
        return True  # admins can delete any post
    
    # Check "delete:own_post"
    if "delete:own_post" in permissions:
        return user.id == post.owner_id  # can delete if you own it
    
    return False  # no delete permission
 
@app.delete('/posts/<post_id>')
def delete_post(post_id):
    if not can_delete_post(request.user, post_id):
        abort(403)
    db.delete_post(post_id)
    return 200

Middleware approach (Django example)

Define a decorator to check permissions:

def requires_permission(permission: str):
    def decorator(view_func):
        def wrapper(request, *args, **kwargs):
            user = request.user
            if not user.is_authenticated:
                return 401
            
            permissions = get_permissions(user.role)
            if permission not in permissions:
                return 403
            
            return view_func(request, *args, **kwargs)
        return wrapper
    return decorator
 
@app.delete('/posts/<post_id>')
@requires_permission("delete:post")
def delete_post_admin_only(post_id):
    db.delete_post(post_id)
    return 200
 
@app.delete('/posts/<post_id>')
def delete_post(post_id):
    if not can_delete_post(request.user, post_id):
        abort(403)
    db.delete_post(post_id)
    return 200

Hierarchical roles

In some systems, roles inherit permissions:

def get_permissions(role: str) -> set[str]:
    permissions = set()
    
    if role in ["admin", "moderator", "editor", "viewer"]:
        permissions.add("read:post")
    
    if role in ["admin", "moderator", "editor"]:
        permissions.update(["create:post", "edit:own_post"])
    
    if role in ["admin", "moderator"]:
        permissions.update(["edit:any_post", "delete:any_post"])
    
    if role == "admin":
        permissions.add("manage_users")
    
    return permissions

RBAC limitations and when to use ABAC

RBAC works well for simple systems with a handful of roles. As the system grows, it can become unwieldy:

Problem: Alice is an editor, but only for her own blog. Bob is an editor for company events. Charlie is an editor for company news but also for events.

You'd end up with many fine-grained roles: editor-blog-personal, editor-events-company, editor-news-company, etc.

Solution: ABAC (Attribute-Based Access Control) makes decisions based on attributes:

def can_edit_post(user: User, post_id: str) -> bool:
    post = db.get_post(post_id)
    
    # Check multiple attributes
    if user.role == "admin":
        return True
    
    if user.role == "editor" and user.id == post.owner_id:
        return True
    
    if user.department == "news" and post.category == "news":
        return True
    
    return False

ABAC is more flexible but also more complex. Most apps start with RBAC and add ABAC when they outgrow it.

Multi-tenant RBAC

In multi-tenant apps, permissions are scoped by organization:

class User:
    id: str
    email: str
 
class UserOrganization:
    user_id: str
    organization_id: str
    role: str  # "admin", "editor", "viewer" (within this org)
 
def can_delete_post(user: User, org_id: str, post_id: str) -> bool:
    # Get user's role in this organization
    user_org = db.get_user_organization(user.id, org_id)
    if not user_org:
        return False  # user doesn't belong to this org
    
    permissions = get_permissions(user_org.role)
    
    # Check permissions within this org
    post = db.get_post(post_id)
    if post.organization_id != org_id:
        return False  # post belongs to a different org
    
    if "delete:post" in permissions:
        return True
    
    if "delete:own_post" in permissions and user.id == post.owner_id:
        return True
    
    return False

The key: users can have different roles in different organizations. Alice is an admin in Company A but a viewer in Company B.

Common mistakes

Trusting the frontend

// WRONG — frontend hides button for non-admins
if (user.role === "admin") {
    showDeleteButton();
}

The frontend is untrusted. A user can open DevTools and make the button appear. Authorization must be enforced on the backend.

Not checking organization membership

# WRONG — doesn't check if user belongs to this org
post = db.get_post(post_id)
if user.role == "editor":
    db.delete_post(post_id)  # allows editing posts from other orgs

CORRECT

post = db.get_post(post_id)
user_org = db.get_user_organization(user.id, post.organization_id)
if user_org and user_org.role == "editor":
    db.delete_post(post_id)

Hardcoding permissions in the view

# WRONG — permissions are hardcoded; hard to change
@app.delete('/posts/<post_id>')
def delete_post(post_id):
    if request.user.role != "admin":
        abort(403)
    db.delete_post(post_id)

CORRECT

def can_perform(user: User, action: str, resource_id: str) -> bool:
    # Permission logic is centralized and testable
    ...
 
@app.delete('/posts/<post_id>')
def delete_post(post_id):
    if not can_perform(request.user, "delete", post_id):
        abort(403)
    db.delete_post(post_id)

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. In RBAC, how do you determine if a user can perform an action?

2. What is the difference between delete:post and delete:own_post permissions?

3. In a multi-tenant app, what must you check when a user tries to access a resource?

4. When should you move from RBAC to ABAC?