authorize(user, permission, resource) middleware/decorator on every protected route.| RBAC Model | Formal Name | Hierarchy | Constraints | Best For | Complexity |
|---|---|---|---|---|---|
| RBAC0 (flat) | Core RBAC | None | None | Small apps, <10 roles | Low |
| RBAC1 (hierarchical) | Hierarchical RBAC | Roles inherit from parent roles | None | Enterprise apps, department trees | Medium |
| RBAC2 (constrained) | Constrained RBAC | None | Mutual exclusion, cardinality limits | Compliance-heavy (finance, healthcare) | High |
| RBAC3 (symmetric) | RBAC1 + RBAC2 | Full hierarchy | Full constraints | Large enterprise with SOX/HIPAA | High |
| ABAC | Attribute-Based | N/A — uses attributes | Attribute policies | Fine-grained, context-dependent | Very High |
| ReBAC | Relationship-Based | Object graph | Relationship tuples | Social apps, document sharing (Google Drive model) | High |
| ACL | Access Control List | N/A — per-object | Per-resource lists | File systems, simple object-level control | Low-Medium |
| Component | Purpose | Storage Options |
|---|---|---|
| Users | Authenticated identities | Identity provider, users table |
| Roles | Named collections of permissions | roles table, policy file |
| Permissions | Actions on resources (read, write, delete) | permissions table, policy engine |
| User-Role mapping | Which users hold which roles | user_roles join table |
| Role-Permission mapping | Which roles grant which permissions | role_permissions join table |
| Sessions | Active role assignments per login | JWT claims, session store |
START
├── Fewer than 5 roles, no hierarchy needed?
│ ├── YES → Use RBAC0 (flat). DB join tables or simple config.
│ └── NO ↓
├── Roles have parent-child relationships (manager inherits team-lead)?
│ ├── YES → Use RBAC1 (hierarchical). Add parent_role_id to roles table or use Casbin.
│ └── NO ↓
├── Need mutual exclusion (user cannot be both auditor and treasurer)?
│ ├── YES → Use RBAC2/RBAC3 (constrained). Add constraint rules to role assignment logic.
│ └── NO ↓
├── Need fine-grained context (time-of-day, IP, resource owner)?
│ ├── YES → RBAC alone is insufficient. Add ABAC layer (OPA, Cedar) or combine RBAC + ownership checks.
│ └── NO ↓
├── Want to manage policies externally (not in app code)?
│ ├── YES → Use a policy engine: Casbin (lightweight), OPA (cloud-native), Cedar (AWS).
│ └── NO ↓
└── DEFAULT → Start with RBAC0 in your DB. Add hierarchy later when you hit >10 roles.
Create the five core tables: users, roles, permissions, user_roles, and role_permissions. This normalized schema prevents permission duplication and enables role reuse. [src1]
-- Core RBAC schema (PostgreSQL)
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
parent_id INT REFERENCES roles(id), -- NULL for RBAC0, used for RBAC1
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
action VARCHAR(50) NOT NULL, -- e.g. 'read', 'write', 'delete'
resource VARCHAR(100) NOT NULL, -- e.g. 'articles', 'users', 'reports'
UNIQUE(action, resource)
);
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
permission_id INT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
role_id INT REFERENCES roles(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, role_id)
);
Verify: SELECT * FROM roles; → expected: your seeded roles visible.
Insert your baseline roles and their associated permissions. Start with the minimum set. [src4]
INSERT INTO roles (name) VALUES ('viewer'), ('editor'), ('admin');
INSERT INTO permissions (action, resource) VALUES
('read', 'articles'), ('write', 'articles'), ('delete', 'articles'),
('read', 'users'), ('write', 'users'), ('delete', 'users');
-- admin: all permissions
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p WHERE r.name = 'admin';
Verify: SELECT r.name, p.action, p.resource FROM role_permissions rp JOIN roles r ON r.id = rp.role_id JOIN permissions p ON p.id = rp.permission_id; → expected: complete permission matrix.
Create a reusable middleware that intercepts every request, resolves permissions, and checks the required permission. [src2]
// Node.js/Express — permission-checking middleware
async function authorize(requiredAction, requiredResource) {
return async (req, res, next) => {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { rows } = await db.query(`
SELECT DISTINCT p.action, p.resource
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1 AND p.action = $2 AND p.resource = $3
`, [userId, requiredAction, requiredResource]);
if (rows.length === 0) return res.status(403).json({ error: 'Forbidden' });
next();
};
}
Verify: GET /articles with viewer token → 200. DELETE /articles/1 with viewer token → 403.
Provide admin-only endpoints to assign and revoke roles. Always check that the assigner has permission to manage roles. [src4]
app.post('/admin/users/:userId/roles',
authorize('write', 'users'),
async (req, res) => {
const { userId } = req.params;
const { roleName } = req.body;
const role = await db.query('SELECT id FROM roles WHERE name = $1', [roleName]);
if (role.rows.length === 0) return res.status(404).json({ error: 'Role not found' });
await db.query(
'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, role.rows[0].id]
);
res.json({ message: `Role '${roleName}' assigned` });
}
);
Verify: POST /admin/users/5/roles {"roleName":"editor"} → 200.
For high-traffic apps, cache resolved permissions to avoid repeated DB joins. Invalidate on role/permission changes. [src4]
const CACHE_TTL = 300; // 5 minutes
async function getPermissions(userId) {
const cacheKey = `perms:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const { rows } = await db.query(`
SELECT DISTINCT p.action || ':' || p.resource AS perm
FROM user_roles ur
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE ur.user_id = $1
`, [userId]);
const perms = rows.map(r => r.perm);
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(perms));
return perms;
}
Verify: redis-cli GET perms:5 → shows permission array.
// Input: authenticated request with req.user.id
// Output: 403 if denied, next() if allowed
const authorize = (action, resource) => async (req, res, next) => {
if (!req.user?.id) return res.status(401).json({ error: 'Unauthenticated' });
const { rows } = await pool.query(`
WITH RECURSIVE role_tree AS (
SELECT r.id FROM user_roles ur JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1
UNION ALL
SELECT r.id FROM roles r JOIN role_tree rt ON r.parent_id = rt.id
)
SELECT 1 FROM role_tree rt
JOIN role_permissions rp ON rp.role_id = rt.id
JOIN permissions p ON p.id = rp.permission_id
WHERE p.action = $2 AND p.resource = $3
LIMIT 1
`, [req.user.id, action, resource]);
if (rows.length === 0) return res.status(403).json({ error: 'Forbidden' });
next();
};
# Input: Django request with request.user
# Output: 403 HttpResponseForbidden or proceeds to view
from functools import wraps
from django.http import HttpResponseForbidden
def require_permission(action, resource):
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return HttpResponseForbidden("Not authenticated")
role_ids = UserRole.objects.filter(
user=request.user
).values_list('role_id', flat=True)
has_perm = RolePermission.objects.filter(
role_id__in=role_ids,
permission__action=action,
permission__resource=resource
).exists()
if not has_perm:
return HttpResponseForbidden("Insufficient permissions")
return view_func(request, *args, **kwargs)
return wrapper
return decorator
// Input: HTTP request with user ID in context
// Output: 403 if Casbin denies, next handler if allowed
func AuthzMiddleware(e *casbin.Enforcer) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value("userID").(string)
obj := r.URL.Path
act := r.Method
ok, err := e.Enforce(user, obj, act)
if err != nil || !ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
// Input: Spring Security authenticated principal
// Output: AccessDeniedException if permission missing
@RestController
@RequestMapping("/articles")
public class ArticleController {
@GetMapping
@PreAuthorize("hasAuthority('read:articles')")
public List<Article> list() {
return articleService.findAll();
}
@PostMapping
@PreAuthorize("hasAuthority('write:articles')")
public Article create(@RequestBody Article article) {
return articleService.save(article);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('delete:articles')")
public void delete(@PathVariable Long id) {
articleService.delete(id);
}
}
// BAD -- tightly couples code to specific role names
if (user.role === 'admin') {
allowDelete();
}
// Adding a "super_admin" role requires changing every check
// GOOD -- checks capability, not identity
if (await hasPermission(user.id, 'delete', 'articles')) {
allowDelete();
}
// New roles automatically work if they have the permission
// BAD -- hiding UI elements is not authorization
{user.role === 'admin' && <DeleteButton />}
// Anyone can call the API directly and bypass this
// GOOD -- server enforces, client hides for UX only
app.delete('/articles/:id', authorize('delete', 'articles'), deleteHandler);
// Client can also conditionally render for better UX
// BAD -- JWT role claim is stale after role revocation
const decoded = jwt.verify(token, secret);
if (decoded.roles.includes('admin')) {
allowAdminAction(); // User was demoted but JWT hasn't expired
}
// GOOD -- JWT identifies user, permissions checked live
const decoded = jwt.verify(token, secret);
const userId = decoded.sub;
const hasAccess = await checkPermission(userId, 'admin_action', 'system');
if (hasAccess) allowAdminAction();
-- BAD -- "god role" that grants everything
INSERT INTO roles (name) VALUES ('super_admin_everything');
-- No granularity, violates least privilege
-- GOOD -- fine-grained, composable
INSERT INTO roles (name) VALUES ('article_manager'), ('user_manager');
-- Super admin = both roles assigned to user
authorize() on every route. [src2]*:* to convenience roles. Fix: Never use wildcards in production; explicitly enumerate permissions. [src4]-- Check a user's effective roles
SELECT r.name FROM user_roles ur
JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = 42;
-- Check effective permissions (including role hierarchy)
WITH RECURSIVE role_tree AS (
SELECT r.id, r.name FROM user_roles ur
JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = 42
UNION ALL
SELECT r.id, r.name FROM roles r
JOIN role_tree rt ON r.parent_id = rt.id
)
SELECT DISTINCT p.action, p.resource
FROM role_tree rt
JOIN role_permissions rp ON rp.role_id = rt.id
JOIN permissions p ON p.id = rp.permission_id;
-- Find users with a specific permission
SELECT DISTINCT u.id, u.email FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN role_permissions rp ON rp.role_id = ur.role_id
JOIN permissions p ON p.id = rp.permission_id
WHERE p.action = 'delete' AND p.resource = 'users';
-- Detect orphaned role assignments
SELECT ur.* FROM user_roles ur
LEFT JOIN roles r ON r.id = ur.role_id WHERE r.id IS NULL;
| Standard / Tool | Version | Status | Key Change |
|---|---|---|---|
| NIST RBAC (INCITS 359) | 2012 | Current standard | Defines RBAC0-RBAC3 formally |
| NIST SP 800-53 Rev. 5 | 2020 | Current | AC-3(7) codifies RBAC as access control policy |
| Casbin | v2.x (2024) | Current | RBAC with domains, pattern matching, priority policies |
| Spring Security | 6.x (2024) | Current | @PreAuthorize with SpEL, method-level security |
| AWS IAM | 2025 | Current | IAM Access Analyzer auto-detects unused permissions |
| Django | 5.x (2025) | Current | Built-in Permission model, @permission_required |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Users fit into well-defined job categories | Permissions depend on object ownership | ReBAC (Zanzibar/SpiceDB) or RBAC + ownership filter |
| You have fewer than ~50 distinct roles | You need fine-grained context (time, location, risk score) | ABAC (OPA, Cedar, XACML) |
| Compliance requires auditable role assignments (SOX, HIPAA) | Access is per-document or per-record | ACL or ReBAC |
| Multi-tenant with shared role definitions per tenant | Roles change so frequently that management overhead exceeds benefit | ABAC with dynamic attributes |
| You want a simple, well-understood model teams can reason about | You have only 2 roles (logged-in vs. not) | Simple boolean isAuthenticated check |