Implement RBAC
How to implement role-based access control with hierarchical roles, permission grants, and middleware enforcement across Python, Node.js, and Java.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Role-Based Access Control (RBAC) assigns permissions to roles, and roles to users. A user inherits all permissions of their roles. This model is simple, auditable, and scales to hundreds of roles without the complexity of attribute-based systems. RBAC is the right choice when access decisions depend primarily on job function rather than dynamic context.
When to Use
- Your application has clearly defined job functions (admin, editor, viewer, auditor)
- Access control changes infrequently — new roles are added quarterly, not per request
- You need an audit trail that is easy to explain to non-technical stakeholders
- Compliance frameworks (SOC 2, ISO 27001) require documented access control matrices
- You want to avoid the complexity of evaluating dynamic policies on every request
When NOT to Use
- Access decisions depend on time, location, device posture, or data sensitivity — use ABAC
- Users need different permissions for different projects, teams, or resources — use resource-level ACLs or ReBAC
- The same user needs to act on behalf of multiple organizations with different roles — use multi-tenant RBAC or ABAC
- You are building a zero-trust architecture where every request is evaluated against dynamic context
Step-by-Step Implementation
Python (Flask + SQLAlchemy)
from enum import Enum
from functools import wraps
from flask import Flask, g, request, jsonify
from sqlalchemy import Column, Integer, String, Table, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
# Association tables
user_roles = Table('user_roles', Base.metadata,
Column('user_id', Integer, ForeignKey('users.id')),
Column('role_id', Integer, ForeignKey('roles.id'))
)
role_permissions = Table('role_permissions', Base.metadata,
Column('role_id', Integer, ForeignKey('roles.id')),
Column('permission_id', Integer, ForeignKey('permissions.id'))
)
class Permission(Base):
__tablename__ = 'permissions'
id = Column(Integer, primary_key=True)
name = Column(String(100), unique=True, nullable=False)
resource = Column(String(100), nullable=False) # e.g., 'users', 'orders'
action = Column(String(50), nullable=False) # e.g., 'read', 'write', 'delete'
class Role(Base):
__tablename__ = 'roles'
id = Column(Integer, primary_key=True)
name = Column(String(50), unique=True, nullable=False)
parent_id = Column(Integer, ForeignKey('roles.id'), nullable=True)
permissions = relationship('Permission', secondary=role_permissions)
parent = relationship('Role', remote_side=[id], backref='children')
def get_all_permissions(self):
"""Recursively collect permissions from parent roles."""
perms = set(self.permissions)
if self.parent:
perms.update(self.parent.get_all_permissions())
return perms
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
email = Column(String(255), unique=True)
roles = relationship('Role', secondary=user_roles)
def has_permission(self, resource, action):
for role in self.roles:
for perm in role.get_all_permissions():
if perm.resource == resource and perm.action == action:
return True
return False
# Decorator for route protection
def require_permission(resource, action):
def decorator(f):
@wraps(f)
def wrapped(*args, **kwargs):
if not g.user or not g.user.has_permission(resource, action):
return jsonify({"error": "Forbidden"}), 403
return f(*args, **kwargs)
return wrapped
return decorator
# Seed roles and permissions
def seed_rbac():
viewer = Role(name='viewer')
viewer.permissions = [
Permission(resource='orders', action='read'),
Permission(resource='reports', action='read')
]
editor = Role(name='editor', parent=viewer)
editor.permissions = [
Permission(resource='orders', action='write'),
Permission(resource='products', action='read')
]
admin = Role(name='admin', parent=editor)
admin.permissions = [
Permission(resource='users', action='read'),
Permission(resource='users', action='write'),
Permission(resource='users', action='delete'),
Permission(resource='settings', action='write')
]
db.add_all([viewer, editor, admin])
db.commit()
# Usage
@app.route('/orders/<int:id>', methods=['PUT'])
@require_permission('orders', 'write')
def update_order(id):
return jsonify({"message": f"Updated order {id}"})
Node.js (Express + Prisma)
// prisma/schema.prisma
model Permission {
id Int @id @default(autoincrement())
name String @unique
resource String
action String
roles Role[]
}
model Role {
id Int @id @default(autoincrement())
name String @unique
parentId Int? @map("parent_id")
parent Role? @relation("RoleHierarchy", fields: [parentId], references: [id])
children Role[] @relation("RoleHierarchy")
permissions Permission[]
users User[]
}
model User {
id Int @id @default(autoincrement())
email String @unique
roles Role[]
}
// middleware/rbac.js
const prisma = require('../prisma/client');
function requirePermission(resource, action) {
return async (req, res, next) => {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
include: {
roles: {
include: {
permissions: true,
parent: {
include: { permissions: true }
}
}
}
}
});
const hasPermission = user.roles.some(role => {
const rolePerms = [...role.permissions];
if (role.parent) rolePerms.push(...role.parent.permissions);
return rolePerms.some(p => p.resource === resource && p.action === action);
});
if (!hasPermission) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
// Usage
app.put('/orders/:id',
authenticate,
requirePermission('orders', 'write'),
updateOrder
);
// Service layer for programmatic checks
class AuthorizationService {
async can(userId, resource, action) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { roles: { include: { permissions: true } } }
});
return user.roles.some(role =>
role.permissions.some(p => p.resource === resource && p.action === action)
);
}
async assignRole(userId, roleName) {
return prisma.user.update({
where: { id: userId },
data: { roles: { connect: { name: roleName } } }
});
}
}
Java (Spring Security)
@Entity
public class Role {
@Id @GeneratedValue
private Long id;
@Column(unique = true, nullable = false)
private String name;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "role_permissions",
joinColumns = @JoinColumn(name = "role_id"),
inverseJoinColumns = @JoinColumn(name = "permission_id")
)
private Set<Permission> permissions = new HashSet<>();
@ManyToOne
@JoinColumn(name = "parent_id")
private Role parent;
public Set<Permission> getAllPermissions() {
Set<Permission> all = new HashSet<>(permissions);
if (parent != null) {
all.addAll(parent.getAllPermissions());
}
return all;
}
}
@Entity
public class Permission {
@Id @GeneratedValue
private Long id;
@Column(nullable = false)
private String resource; // e.g., "orders"
@Column(nullable = false)
private String action; // e.g., "READ", "WRITE"
}
@Entity
public class User {
@Id @GeneratedValue
private Long id;
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
public boolean hasPermission(String resource, String action) {
return roles.stream()
.flatMap(role -> role.getAllPermissions().stream())
.anyMatch(p -> p.getResource().equals(resource) && p.getAction().equals(action));
}
}
// Spring Security method-level security
@PreAuthorize("hasAuthority('orders:write')")
public Order updateOrder(Long orderId, OrderUpdateRequest request) {
// ...
}
// Custom permission evaluator
@Component
public class ResourcePermissionEvaluator implements PermissionEvaluator {
@Autowired
private UserRepository userRepository;
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
String email = auth.getName();
User user = userRepository.findByEmail(email);
String[] parts = ((String) permission).split(":");
return user.hasPermission(parts[0], parts[1]);
}
}
Best Practices
- Define permissions as resource + action pairs.
orders:readis clearer thanVIEWERand allows fine-grained composition. Roles are groupings of permissions, not replacements for them. - Implement role hierarchy, not role duplication. If editors can do everything viewers can, make
editorinherit fromviewerinstead of copying all viewer permissions to the editor role. - Deny by default. If a user has no explicit permission for an action, the answer is always
false. Do not implement “allow unless denied” logic. - Cache permission lookups. Walking role hierarchies and joining tables on every request is expensive. Cache the effective permission set per user in Redis or in the JWT/session.
- Audit permission changes. Log every grant, revoke, and role assignment with who made the change and when. RBAC only works if the assignment trail is trustworthy.
Common Mistakes
- Using role names as permissions.
if (user.role == 'admin')hardcodes business logic in code. When the organization adds asupervisorrole betweeneditorandadmin, every check breaks. - Not considering role hierarchy in authorization checks. A user with the
adminrole is also implicitly aneditorandviewerif the hierarchy is configured. Forgetting this causes false denials. - Storing roles in JWTs without expiration. A user demoted from
admintoviewerwill retain admin access until their JWT expires. Use short-lived tokens or maintain a revocation list. - Ignoring the principle of least privilege. Default roles with broad permissions (
usercan read everything) expose data that should be restricted. - Not testing authorization logic. Unit tests verify business logic but rarely test that
viewercannot callDELETE /users/1. Add explicit authorization test cases.