Roles and Permissions
24 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Authentication says who you are. Authorisation says what you may do. A user who is logged in must still be stopped from reading another customer's order or opening an admin page. This lesson builds those checks as small FastAPI dependencies.
Who you are and what you may do
Authorisation is the check of what an identified user is allowed to do. It runs after authentication, on every request, for every action and every record.
Authentication proves identity. Authorisation checks permission for one action. They use different status codes: 401 means we do not know who you are, 403 means we know you and the answer is no.
def status_for(user, allowed_roles): if user is None: return 401 # not logged in if user["role"] not in allowed_roles: return 403 # logged in, not allowed return 200A role is a label such as customer, support or admin. Keep roles few and put the rule in one place, not scattered across routes.
A dependency that checks the role
Write the role check once as a dependency factory. require_roles("admin") returns a function FastAPI runs before the route. If the check fails, the route body never runs.
def require_roles(*roles): def checker(user: Annotated[dict, Depends(current_user)]): if user["role"] not in roles: raise HTTPException(403, "You do not have permission") return user return checker
@app.get("/admin/reports")def reports(user=Depends(require_roles("admin"))): ...Example 1 · A role dependency: 401, 401, 403, 403, 200
from typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearerfrom fastapi.testclient import TestClient
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")TOKENS = { "t-asha": {"id": 1, "role": "customer"}, "t-ravi": {"id": 2, "role": "support"}, "t-meena": {"id": 3, "role": "admin"},}ORDERS = {10: {"id": 10, "owner_id": 1, "total": 500}, 11: {"id": 11, "owner_id": 4, "total": 900}}
def current_user(token: Annotated[str, Depends(oauth2)]): user = TOKENS.get(token) if user is None: raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"}) return user
def require_roles(*roles): def checker(user: Annotated[dict, Depends(current_user)]): if user["role"] not in roles: raise HTTPException(403, "You do not have permission") return user return checker
def h(name): return {"Authorization": "Bearer " + name}
@app.get("/admin/reports")def reports(user: Annotated[dict, Depends(require_roles("admin"))]): return {"report": "sales"}
client = TestClient(app)for label, headers in [("no token", {}), ("bad token", h("zzz")), ("customer", h("t-asha")), ("support", h("t-ravi")), ("admin", h("t-meena"))]: r = client.get("/admin/reports", headers=headers) print(label, r.status_code, r.json().get("detail", "ok"))no token 401 Not authenticated bad token 401 Not authenticated customer 403 You do not have permission support 403 You do not have permission admin 200 ok
What this code does
- No token and a bad token give 401. A known user without the role gives 403. Only the admin gets in.
- The role check is written once and used with one line per route.
The checker itself depends on current_user, so a missing token still gives 401 first, then a wrong role gives 403.
Permissions and deny by default
Permission is one named action a user may perform, such as orders:refund. Roles are bundles of permissions, and code checks the permission, so a new role needs no route changes.
Roles can grow into long lists of special cases. A permission is one action, such as orders:refund. Each role lists its permissions, and routes ask for a permission, not a role. Anything not listed is refused.
ROLE_PERMISSIONS = { "customer": {"orders:read_own"}, "support": {"orders:read_own", "orders:read_any"}, "admin": {"orders:read_own", "orders:read_any", "orders:refund"},}
def can(role, permission): return permission in ROLE_PERMISSIONS.get(role, set())Example 2 · Permissions as a table, deny by default
ROLE_PERMISSIONS = { "customer": {"orders:read_own"}, "support": {"orders:read_own", "orders:read_any"}, "admin": {"orders:read_own", "orders:read_any", "orders:refund"},}
def can(role, permission): return permission in ROLE_PERMISSIONS.get(role, set())
for role in ("customer", "support", "admin", "intern", None): print(role, can(role, "orders:read_any"), can(role, "orders:refund"))customer False False support True False admin True True intern False False None False False
What this code does
- Each role maps to a set of permissions. The unknown role and
Noneget an empty set, so every check is False. - The table is plain data, so it is easy to test and to review.
An unknown role gets an empty set, so it is denied. This is deny by default: forgetting to add a rule makes things safer, not more open.
Check the record, not just the route
A role check says customers may read orders. It does not say which orders. Without an object-level check, a customer can read anyone's order by changing the id in the URL. Always compare the record's owner with the current user.
order = ORDERS.get(order_id)if order is None or (order["owner_id"] != user["id"] and not can(user["role"], "orders:read_any")): raise HTTPException(404, "Order not found")Example 3 · Object-level check on one order
from typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearerfrom fastapi.testclient import TestClient
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")TOKENS = { "t-asha": {"id": 1, "role": "customer"}, "t-ravi": {"id": 2, "role": "support"}, "t-meena": {"id": 3, "role": "admin"},}ORDERS = {10: {"id": 10, "owner_id": 1, "total": 500}, 11: {"id": 11, "owner_id": 4, "total": 900}}
def current_user(token: Annotated[str, Depends(oauth2)]): user = TOKENS.get(token) if user is None: raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"}) return user
def require_roles(*roles): def checker(user: Annotated[dict, Depends(current_user)]): if user["role"] not in roles: raise HTTPException(403, "You do not have permission") return user return checker
def h(name): return {"Authorization": "Bearer " + name}
@app.get("/orders/{order_id}")def get_order(order_id: int, user: Annotated[dict, Depends(current_user)]): order = ORDERS.get(order_id) if order is None or (order["owner_id"] != user["id"] and user["role"] not in ("support", "admin")): raise HTTPException(404, "Order not found") return order
client = TestClient(app)print(client.get("/orders/10", headers=h("t-asha")).status_code)print(client.get("/orders/11", headers=h("t-asha")).status_code)print(client.get("/orders/99", headers=h("t-asha")).status_code)print(client.get("/orders/11", headers=h("t-ravi")).json()["total"])200 404 404 900
What this code does
- Customer asha owns order 10 only. Order 11 belongs to someone else, so she gets 404, the same as for an id that does not exist.
- Support may read any order, so ravi sees the total of order 11.
Example 4 · Routes that ask for a permission
from typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearerfrom fastapi.testclient import TestClient
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")TOKENS = { "t-asha": {"id": 1, "role": "customer"}, "t-ravi": {"id": 2, "role": "support"}, "t-meena": {"id": 3, "role": "admin"},}ORDERS = {10: {"id": 10, "owner_id": 1, "total": 500}, 11: {"id": 11, "owner_id": 4, "total": 900}}
def current_user(token: Annotated[str, Depends(oauth2)]): user = TOKENS.get(token) if user is None: raise HTTPException(401, "Not authenticated", headers={"WWW-Authenticate": "Bearer"}) return user
def require_roles(*roles): def checker(user: Annotated[dict, Depends(current_user)]): if user["role"] not in roles: raise HTTPException(403, "You do not have permission") return user return checker
def h(name): return {"Authorization": "Bearer " + name}
ROLE_PERMISSIONS = { "customer": {"orders:read_own"}, "support": {"orders:read_own", "orders:read_any"}, "admin": {"orders:read_own", "orders:read_any", "orders:refund"},}
def require_permission(permission): def checker(user: Annotated[dict, Depends(current_user)]): if permission not in ROLE_PERMISSIONS.get(user["role"], set()): raise HTTPException(403, "Missing permission: " + permission) return user return checker
@app.post("/orders/{order_id}/refund")def refund(order_id: int, user: Annotated[dict, Depends(require_permission("orders:refund"))]): return {"order": order_id, "refunded_by": user["id"]}
client = TestClient(app)for name in ("t-asha", "t-ravi", "t-meena"): r = client.post("/orders/10/refund", headers=h(name)) print(name, r.status_code, r.json())t-asha 403 {'detail': 'Missing permission: orders:refund'}
t-ravi 403 {'detail': 'Missing permission: orders:refund'}
t-meena 200 {'order': 10, 'refunded_by': 3}What this code does
- The route asks for
orders:refund, not for a role name. Only the admin role holds it. - If a new
financerole must refund, add the permission to its set. No route changes.
Answering 404 for records the user may not see also hides which ids exist. Use 403 when the user already knows the record exists and only the action is forbidden.
Next step
Check your understanding
12 quick questions