Role-Based Access Control (RBAC): Complete Reference

Type: Software Reference Confidence: 0.90 Sources: 7 Verified: 2026-02-24 Freshness: 2026-02-24

TL;DR

Constraints

Quick Reference

RBAC ModelFormal NameHierarchyConstraintsBest ForComplexity
RBAC0 (flat)Core RBACNoneNoneSmall apps, <10 rolesLow
RBAC1 (hierarchical)Hierarchical RBACRoles inherit from parent rolesNoneEnterprise apps, department treesMedium
RBAC2 (constrained)Constrained RBACNoneMutual exclusion, cardinality limitsCompliance-heavy (finance, healthcare)High
RBAC3 (symmetric)RBAC1 + RBAC2Full hierarchyFull constraintsLarge enterprise with SOX/HIPAAHigh
ABACAttribute-BasedN/A — uses attributesAttribute policiesFine-grained, context-dependentVery High
ReBACRelationship-BasedObject graphRelationship tuplesSocial apps, document sharing (Google Drive model)High
ACLAccess Control ListN/A — per-objectPer-resource listsFile systems, simple object-level controlLow-Medium
ComponentPurposeStorage Options
UsersAuthenticated identitiesIdentity provider, users table
RolesNamed collections of permissionsroles table, policy file
PermissionsActions on resources (read, write, delete)permissions table, policy engine
User-Role mappingWhich users hold which rolesuser_roles join table
Role-Permission mappingWhich roles grant which permissionsrole_permissions join table
SessionsActive role assignments per loginJWT claims, session store

Decision Tree

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.

Step-by-Step Guide

1. Design the database schema

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.

2. Seed initial roles and permissions

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.

3. Build authorization middleware

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.

4. Implement role assignment API

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.

5. Add permission caching

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.

Code Examples

Node.js/Express: Permission middleware with role hierarchy

// 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();
};

Python/Django: Decorator-based permission check

# 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

Go: Middleware with Casbin policy engine

// 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)
        })
    }
}

Java/Spring Security: Method-level authorization

// 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);
    }
}

Anti-Patterns

Wrong: Hardcoding role names in business logic

// BAD -- tightly couples code to specific role names
if (user.role === 'admin') {
  allowDelete();
}
// Adding a "super_admin" role requires changing every check

Correct: Check permissions, not role names

// GOOD -- checks capability, not identity
if (await hasPermission(user.id, 'delete', 'articles')) {
  allowDelete();
}
// New roles automatically work if they have the permission

Wrong: Client-side only authorization

// BAD -- hiding UI elements is not authorization
{user.role === 'admin' && <DeleteButton />}
// Anyone can call the API directly and bypass this

Correct: Server-side enforcement with optional UI hiding

// GOOD -- server enforces, client hides for UX only
app.delete('/articles/:id', authorize('delete', 'articles'), deleteHandler);
// Client can also conditionally render for better UX

Wrong: Storing roles in JWT without server-side validation

// 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
}

Correct: Use JWT for identity, check permissions from DB/cache

// 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();

Wrong: One mega-role instead of composable permissions

-- BAD -- "god role" that grants everything
INSERT INTO roles (name) VALUES ('super_admin_everything');
-- No granularity, violates least privilege

Correct: Compose roles from granular permissions

-- GOOD -- fine-grained, composable
INSERT INTO roles (name) VALUES ('article_manager'), ('user_manager');
-- Super admin = both roles assigned to user

Common Pitfalls

Diagnostic Commands

-- 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;

Version History & Compatibility

Standard / ToolVersionStatusKey Change
NIST RBAC (INCITS 359)2012Current standardDefines RBAC0-RBAC3 formally
NIST SP 800-53 Rev. 52020CurrentAC-3(7) codifies RBAC as access control policy
Casbinv2.x (2024)CurrentRBAC with domains, pattern matching, priority policies
Spring Security6.x (2024)Current@PreAuthorize with SpEL, method-level security
AWS IAM2025CurrentIAM Access Analyzer auto-detects unused permissions
Django5.x (2025)CurrentBuilt-in Permission model, @permission_required

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Users fit into well-defined job categoriesPermissions depend on object ownershipReBAC (Zanzibar/SpiceDB) or RBAC + ownership filter
You have fewer than ~50 distinct rolesYou 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-recordACL or ReBAC
Multi-tenant with shared role definitions per tenantRoles change so frequently that management overhead exceeds benefitABAC with dynamic attributes
You want a simple, well-understood model teams can reason aboutYou have only 2 roles (logged-in vs. not)Simple boolean isAuthenticated check

Important Caveats

Related Units