RBAC & Scopes — Roles, Permissions, Ownership
AdvancedLayer authorization: coarse roles via require_role, fine-grained permissions via a role→permission map, OAuth2 scopes via SecurityScopes when tokens must carry grants — plus the ownership checks RBAC alone misses.
Overview
Authentication says who you are; authorization says what you may do — and it layers. Coarse role gates (student/tpo/admin) reuse the require_role dependency factory, now reading the role from JWT claims. As products grow, checking permissions beats checking roles: a role→permissions mapping means "can this user drives:create?" survives every reorganisation of what a TPO is allowed to do — add a role, touch one dict, no endpoint changes. OAuth2 scopes bake grants into the token itself, and FastAPI's SecurityScopes + Security() machinery enforces per-endpoint scope requirements with docs support — the right tool for third-party API access and service-to-service calls. The layer everyone forgets until the pentest: object ownership. Role checks pass, but student 7 must still not read student 9's offer letter — resource-level checks (IDOR protection) belong in the service layer, on every single object access.
Roles → Permissions — Check Capabilities, Not Titles
The JWT carries the role; a single mapping expands it to permissions; require_permission asks "can they?", not "are they?". New roles or reshuffled powers touch the map, never the endpoints.
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
ROLE_PERMISSIONS = {
"student": {"drives:read", "applications:create", "profile:write"},
"tpo": {"drives:read", "drives:create", "drives:close",
"students:read", "reports:read"},
"admin": {"*"}, # everything
}
def require_permission(permission: str):
def checker(user: dict = Depends(get_current_user)) -> dict: # JWT chapter
perms = ROLE_PERMISSIONS.get(user["role"], set())
if "*" not in perms and permission not in perms:
raise HTTPException(403, f"requires permission: {permission}")
return user
return checker
@app.post("/drives", status_code=201)
def create_drive(user: dict = Depends(require_permission("drives:create"))):
return {"created_by": user["email"]}
@app.get("/reports/placements")
def placement_report(user: dict = Depends(require_permission("reports:read"))):
return {"placed": 212, "total": 260}
# Why permissions beat raw roles:
# "TPOs can now also close drives" → add one string to the tpo set. Done.
# New "placement-coordinator" role → one new dict entry. Done.
# With require_role("tpo") sprinkled on endpoints, both changes mean
# hunting every endpoint that mentions a role. The map is the policy.
# At scale the map moves to the DB (editable per tenant), and the
# gyaan-api pattern applies: org_id scoping + role per membership.OAuth2 Scopes with SecurityScopes + the Ownership Layer
Scopes put grants inside the token — enforced by Security(), rendered in /docs. And below every role system sits the check that stops IDOR: does THIS user own THIS object?
from fastapi import Depends, FastAPI, HTTPException, Security
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
import jwt
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="token",
scopes={"drives:read": "View drives", "drives:write": "Manage drives",
"reports:read": "View reports"}, # documented in /docs Authorize
)
def current_user_scoped(security: SecurityScopes,
token: str = Depends(oauth2_scheme)) -> dict:
claims = jwt.decode(token, SECRET, algorithms=["HS256"])
granted = set(claims.get("scopes", [])) # scopes live IN the token
missing = [s for s in security.scopes if s not in granted]
if missing:
raise HTTPException(
403, f"missing scopes: {missing}",
headers={"WWW-Authenticate":
f'Bearer scope="{security.scope_str}"'},
)
return {"email": claims["sub"], "scopes": granted}
@app.get("/reports/summary")
def summary(user: dict = Security(current_user_scoped, scopes=["reports:read"])):
return {"ok": True}
# Scopes vs roles: scopes = what THIS TOKEN may do (third-party grants,
# service-to-service, least-privilege CI tokens); roles = what the USER is.
# Many systems need both: role for humans, scoped tokens for machines.
# ── The layer RBAC misses: OWNERSHIP (IDOR) ──
@app.get("/applications/{app_id}")
def get_application(app_id: int, user: dict = Depends(get_current_user)):
application = load_application(app_id) # service/repo layer
if not application:
raise HTTPException(404, "not found")
if user["role"] != "tpo" and application.student_email != user["email"]:
raise HTTPException(404, "not found") # 404, not 403: don't confirm
return application # the object even EXISTS
# Student 7 fetching /applications/9's data despite valid auth = IDOR,
# the most common real-world API vulnerability. Check on EVERY object access.Key Points to Remember
- 1Layer it: role gate → permission map → object ownership, each catching what the last misses
- 2Check permissions ("drives:create"), not roles — policy changes touch one map
- 3Scopes are per-token grants enforced via SecurityScopes/Security(); ideal for machine access
- 4IDOR: valid users reading others' objects — ownership check on every access, return 404
Interview Questions
Sign in to ask AriaDesign authorization for a placement portal: students, TPOs, admins — roles or permissions, and where do checks live?
What is IDOR? Why does RBAC not prevent it, and why respond 404 instead of 403?
When do OAuth2 scopes earn their complexity over role checks?
Ask Aria about RBAC & Scopes — Roles, Permissions, Ownership
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.