Role-Based Access Control (RBAC) Guide
Role-Based Access Control (RBAC) assigns users to roles (admin, user, moderator), and routes check if a user's role is permitted. Unlike scopes (fine-grained, per-action), roles are coarse-grained categories. RBAC is ideal for internal teams and applications with clear user hierarchies (admin, editor, viewer). Scopes are better for third-party integrations where you need precise permission boundaries.
RBAC vs. Scopes vs. Attributes
| Model | Unit | Example | Best For |
|---|---|---|---|
| RBAC | Roles | admin, user, guest | Internal teams, clear hierarchies |
| Scopes | Permissions | users:read, posts:write | OAuth2, delegated access |
| ABAC | Attributes | age > 18, department = eng | Complex policies, flexible rules |
Designing Roles and Permissions
Define roles and their permissions upfront:
# roles.py
from enum import Enum
class Role(str, Enum):
ADMIN = "admin" # Full access
MODERATOR = "moderator" # Manage content, users
EDITOR = "editor" # Create/edit own posts
VIEWER = "viewer" # Read-only
# Define role hierarchies (admin > moderator > editor > viewer)
ROLE_HIERARCHY = {
Role.ADMIN: [Role.ADMIN, Role.MODERATOR, Role.EDITOR, Role.VIEWER],
Role.MODERATOR: [Role.MODERATOR, Role.EDITOR, Role.VIEWER],
Role.EDITOR: [Role.EDITOR, Role.VIEWER],
Role.VIEWER: [Role.VIEWER]
}
# Map roles to permissions
ROLE_PERMISSIONS = {
Role.ADMIN: ["read_users", "write_users", "delete_users", "manage_roles"],
Role.MODERATOR: ["read_users", "manage_content", "moderate"],
Role.EDITOR: ["write_posts", "read_posts"],
Role.VIEWER: ["read_posts"]
}
Storing Roles in the Database
Add a roles field to your user model:
# models.py
from sqlalchemy import Column, String, List
from enum import Enum
class UserRole(str, Enum):
ADMIN = "admin"
MODERATOR = "moderator"
EDITOR = "editor"
VIEWER = "viewer"
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True)
hashed_password = Column(String)
roles = Column(String) # Comma-separated: "admin,moderator"
is_active = Column(Boolean, default=True)
def get_roles(self) -> list[str]:
"""Return list of user's roles."""
return [r.strip() for r in self.roles.split(",")] if self.roles else []
def set_roles(self, roles: list[str]):
"""Set user's roles."""
self.roles = ",".join(roles)
def has_role(self, role: str) -> bool:
"""Check if user has a specific role."""
return role in self.get_roles()
def has_any_role(self, roles: list[str]) -> bool:
"""Check if user has any of the specified roles."""
return any(r in self.get_roles() for r in roles)
Embedding Roles in JWT Tokens
Include roles in the JWT payload:
# security.py
from jose import jwt
from datetime import datetime, timedelta
from roles import Role
def create_access_token(
data: dict,
roles: list[str] | None = None,
expires_delta: timedelta | None = None
) -> str:
"""Create JWT with roles."""
to_encode = data.copy()
if roles:
to_encode["roles"] = roles
else:
to_encode["roles"] = []
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
Update login to include roles:
# main.py
@app.post("/login", response_model=TokenResponse)
async def login(credentials: LoginRequest, db: Session = Depends(get_db)):
"""Login and issue token with roles."""
user = db.query(User).filter(User.username == credentials.username).first()
if not user or not verify_password(credentials.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
roles = user.get_roles()
token = create_access_token(
data={"sub": user.username, "email": user.email},
roles=roles
)
return {"access_token": token, "token_type": "bearer"}
Checking Roles on Protected Routes
Create a dependency that validates roles:
# security.py
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
bearer_scheme = HTTPBearer()
def decode_token(token: str) -> dict | None:
"""Decode and validate JWT."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.JWTError:
return None
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)
) -> dict:
"""Extract user info from token."""
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
return {
"username": payload.get("sub"),
"roles": payload.get("roles", [])
}
async def require_role(
required_roles: list[str],
current_user: dict = Depends(get_current_user)
) -> dict:
"""Dependency to check if user has required role."""
user_roles = current_user.get("roles", [])
if not any(role in user_roles for role in required_roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of roles: {', '.join(required_roles)}"
)
return current_user
Use it on protected routes:
# main.py
from functools import partial
@app.get("/admin/users")
async def list_users(
current_user: dict = Depends(partial(require_role, ["admin"]))
):
"""List all users. Admin only."""
return {"users": [...]}
@app.post("/posts")
async def create_post(
content: str,
current_user: dict = Depends(partial(require_role, ["admin", "editor"]))
):
"""Create post. Requires admin or editor role."""
return {"post_id": "...", "author": current_user["username"]}
Role Hierarchy and Inheritance
Simplify permission checking using role hierarchies:
# security.py
from roles import ROLE_HIERARCHY
def has_required_role(user_roles: list[str], required_roles: list[str]) -> bool:
"""Check if user has required role, respecting hierarchy."""
for user_role in user_roles:
# Get all roles below this user's role in hierarchy
inherited_roles = ROLE_HIERARCHY.get(user_role, [])
if any(req in inherited_roles for req in required_roles):
return True
return False
async def require_role_hierarchy(
required_roles: list[str],
current_user: dict = Depends(get_current_user)
) -> dict:
"""Check roles with hierarchy support."""
user_roles = current_user.get("roles", [])
if not has_required_role(user_roles, required_roles):
raise HTTPException(status_code=403, detail="Insufficient role")
return current_user
With hierarchy, an admin can perform any action a moderator or editor can:
@app.get("/moderate/comments")
async def get_pending_comments(
current_user: dict = Depends(
partial(require_role_hierarchy, ["moderator", "editor"])
)
):
"""Moderators and above can access."""
return {"comments": [...]}
Role Management Endpoints
Allow admins to assign/revoke roles:
# main.py
from pydantic import BaseModel
class AssignRoleRequest(BaseModel):
username: str
roles: list[str] # e.g., ["editor", "moderator"]
@app.post("/admin/assign-role")
async def assign_role(
request: AssignRoleRequest,
current_user: dict = Depends(partial(require_role, ["admin"])),
db: Session = Depends(get_db)
):
"""Assign roles to a user. Admin only."""
target_user = db.query(User).filter(User.username == request.username).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
# Validate roles
valid_roles = [r.value for r in Role]
if not all(role in valid_roles for role in request.roles):
raise HTTPException(status_code=400, detail="Invalid roles")
target_user.set_roles(request.roles)
db.commit()
return {"username": target_user.username, "roles": target_user.get_roles()}
@app.get("/admin/users/{username}/roles")
async def get_user_roles(
username: str,
current_user: dict = Depends(partial(require_role, ["admin", "moderator"])),
db: Session = Depends(get_db)
):
"""Fetch a user's roles."""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"username": user.username, "roles": user.get_roles()}
Testing RBAC
# test_rbac.py
from fastapi.testclient import TestClient
from main import app
from security import create_access_token
client = TestClient(app)
def test_admin_endpoint_requires_admin_role():
"""Test that admin endpoint rejects non-admin users."""
token = create_access_token(
data={"sub": "alice"},
roles=["viewer"] # Only viewer role
)
response = client.get(
"/admin/users",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 403
def test_admin_endpoint_accepts_admin():
"""Test that admin endpoint accepts admin users."""
token = create_access_token(
data={"sub": "admin_user"},
roles=["admin"]
)
response = client.get(
"/admin/users",
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == 200
Key Takeaways
- RBAC assigns users to roles; routes check if the user's role is allowed.
- Embed roles in JWT tokens or look them up per-request from the database.
- Role hierarchies (admin > moderator > editor > viewer) simplify permission checking.
- Provide admin endpoints to assign/revoke roles without password changes.
- Combine RBAC with scopes for fine-grained OAuth2 delegated access.
Frequently Asked Questions
Should I store roles in the JWT or database?
In JWT: fast but can't revoke immediately (wait for token expiry). In database: flexible and instant but requires a lookup per request. Hybrid: embed in JWT, but re-check database on sensitive operations or use short-lived tokens.
Can a user have multiple roles?
Yes. A user might be both editor and moderator. Check if they have any required role: if any(r in user_roles for r in required_roles).
How do I handle dynamic permissions outside predefined roles?
Use ABAC (Attribute-Based Access Control). Store custom policies in a database: if user.department == "finance" and action == "approve_expense". Check policies at runtime. Keycloak and Auth0 support this.
What's the difference between roles and scopes in my API?
Roles (admin, user) are coarse. Scopes (users:read, posts:write) are fine-grained. Use roles for internal teams; use scopes for public APIs and third-party integrations.