Skip to main content

Role-Based Access Control

Authentication answers "who are you?" — authorization answers "what are you allowed to do?" RBAC (Role-Based Access Control) assigns roles to users and restricts endpoints based on those roles.

Learning Focus

By the end of this lesson you can: implement role checking with dependency functions, use OAuth2 scopes for fine-grained permissions, and test authorization boundaries in pytest.

Simple Role Check

app/dependencies/auth.py
from fastapi import HTTPException, status

def require_role(*roles: str):
"""Factory that returns a dependency checking for one of the allowed roles."""
async def _check(user: CurrentUser) -> UserORM:
if user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required role(s): {', '.join(roles)}",
)
return user
return Depends(_check)
app/routers/admin.py
from fastapi import APIRouter
from app.dependencies.auth import require_role

router = APIRouter(prefix="/admin", tags=["admin"])

AdminUser = Annotated[UserORM, require_role("admin")]
StaffUser = Annotated[UserORM, require_role("admin", "staff")]

@router.get("/dashboard")
async def admin_dashboard(user: AdminUser) -> dict:
return {"role": user.role, "dashboard": "admin view"}

@router.get("/reports")
async def view_reports(user: StaffUser) -> dict:
return {"role": user.role, "reports": []}

Role Hierarchy

app/core/roles.py
ROLE_HIERARCHY = {
"superadmin": 4,
"admin": 3,
"staff": 2,
"user": 1,
"anonymous": 0,
}

def has_minimum_role(user_role: str, required_role: str) -> bool:
return ROLE_HIERARCHY.get(user_role, 0) >= ROLE_HIERARCHY.get(required_role, 0)
app/dependencies/auth.py
from app.core.roles import has_minimum_role

def require_minimum_role(role: str):
async def _check(user: CurrentUser) -> UserORM:
if not has_minimum_role(user.role, role):
raise HTTPException(403, f"Minimum role '{role}' required")
return user
return Depends(_check)

OAuth2 Scopes

For fine-grained permissions, use OAuth2 scopes:

app/core/security.py
from fastapi.security import OAuth2PasswordBearer, SecurityScopes

oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/auth/token",
scopes={
"items:read": "Read items",
"items:write": "Create and update items",
"admin": "Full admin access",
},
)
app/dependencies/auth.py
from fastapi import Security
from fastapi.security import SecurityScopes
from jose import JWTError

async def get_current_user_with_scopes(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)],
db: DBSession,
) -> UserORM:
if security_scopes.scopes:
auth_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
auth_value = "Bearer"

try:
payload = decode_access_token(token)
token_scopes = payload.get("scopes", [])
for scope in security_scopes.scopes:
if scope not in token_scopes:
raise HTTPException(
status_code=403,
detail=f"Scope '{scope}' required",
headers={"WWW-Authenticate": auth_value},
)
except JWTError:
raise HTTPException(401, "Invalid token", headers={"WWW-Authenticate": auth_value})

user = await get_user_by_id(db, int(payload["sub"]))
return user
app/routers/items.py
from fastapi import Security
from app.dependencies.auth import get_current_user_with_scopes

@router.get("/", response_model=list[ItemResponse])
async def list_items(
user: Annotated[UserORM, Security(get_current_user_with_scopes, scopes=["items:read"])]
) -> list:
...

@router.post("/", status_code=201)
async def create_item(
body: ItemCreate,
user: Annotated[UserORM, Security(get_current_user_with_scopes, scopes=["items:write"])]
) -> dict:
...

Testing Authorization

tests/test_rbac.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies.auth import get_current_user

@pytest.fixture
def staff_client():
async def override():
return MockUser(id=1, role="staff")
app.dependency_overrides[get_current_user] = override
yield TestClient(app)
app.dependency_overrides.clear()

@pytest.fixture
def user_client():
async def override():
return MockUser(id=2, role="user")
app.dependency_overrides[get_current_user] = override
yield TestClient(app)
app.dependency_overrides.clear()

def test_admin_endpoint_forbidden_for_user(user_client):
resp = user_client.get("/admin/dashboard")
assert resp.status_code == 403

def test_admin_endpoint_allowed_for_staff(staff_client):
resp = staff_client.get("/admin/reports")
assert resp.status_code == 200

Common Pitfalls

PitfallCause / SymptomFix
Role stored only in JWTUser's role changed in DB but old token still grants accessAlso check DB role on sensitive operations or use short-lived tokens
All routes publicForgot to add auth dependencyAudit all routes with a test that verifies 401 without a token
Role check in handler logicRepeated if user.role != "admin" everywhereExtract to a dependency factory
403 vs 404 confusionAdmin resources returning 403 reveals existenceReturn 404 for admin-only resources to anonymous users
Scope string typosScope not matching exactlyDefine scope constants, never raw strings

Hands-On Practice

tests/test_authorization.py
"""
Systematically test every auth boundary:
- No token → 401
- Valid token, wrong role → 403
- Valid token, correct role → 200/201
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app

ENDPOINTS = [
("GET", "/admin/dashboard", "admin"),
("GET", "/admin/reports", "staff"),
("GET", "/users/me", "user"),
]

@pytest.mark.parametrize("method,path,min_role", ENDPOINTS)
def test_unauthenticated_returns_401(method: str, path: str, min_role: str):
client = TestClient(app)
resp = client.request(method, path)
assert resp.status_code == 401, f"{method} {path} should require auth"

What's Next