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
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
| Role stored only in JWT | User's role changed in DB but old token still grants access | Also check DB role on sensitive operations or use short-lived tokens |
| All routes public | Forgot to add auth dependency | Audit all routes with a test that verifies 401 without a token |
| Role check in handler logic | Repeated if user.role != "admin" everywhere | Extract to a dependency factory |
| 403 vs 404 confusion | Admin resources returning 403 reveals existence | Return 404 for admin-only resources to anonymous users |
| Scope string typos | Scope not matching exactly | Define 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"