RBAC in FastAPI that survives production
Dependency-level guards, scope tables, and the mistakes I fixed in Support CRM.
Guards belong in dependencies, not handlers
The mistake I kept making in Support CRM was checking roles inside each endpoint: if user.role != admin: raise 403. It works until you have 14 endpoints and three role tiers, then every new route is a chance to forget a check.
The fix is dependency-level guards. A single require_role('agent', 'admin') dependency resolves the user, validates scopes, and either returns a typed User or raises 401/403 before the handler runs. Handlers stay thin and every route declares its policy in its signature.
async def require_role(*roles: str):
async def guard(user: User = Depends(get_current_user)):
if user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient scope")
return user
return guardModel scopes as data, not if-statements
Role names drift. What stays stable is the scope table: which role can do what on which resource. I keep a small matrix (role x action x resource) and test it directly, so adding a supervisor tier touches data, not branching logic.
Audit everything the guard rejects. Half my 403 investigations in production were mis-assigned roles, not attacks — the rejection log told me in seconds.
Checklist before you ship
Access and refresh tokens are short-lived and rotated; password hashing is bcrypt; every 403 is logged with user, route, and required scope; dashboard queries hit indexed status and assignee columns so the guard never becomes the slow part.
Building something that needs this rigor?
Send a 2-line brief — I reply with architecture thoughts, not fluff.
Hire me