Learning Goals
3 minBy the end of this lesson you can:
- Model roles → permissions and assign roles to users (RBAC).
- Enforce access with decorators that deny by default.
- Combine role checks with ownership checks (resource-level access).
- Apply least privilege and avoid privilege-escalation pitfalls.
Warm-Up · Roles Beat Per-User Rules
5 minImagine checking, on every route, "is this specific user allowed?" — unmanageable as users grow. Instead, group permissions into roles (admin, editor, reader), assign each user a role, and check the role. Add a person? Give them a role. Change what editors can do? Edit one role, not every user.
Role-Based Access Control (RBAC) is the structured answer to Lesson 28's "authorize every request." Define permissions (the actions), bundle them into roles, assign roles to users, and enforce with decorators that deny by default. Crucially, RBAC handles "what can this kind of user do" — but you still need ownership checks for "is this their record." Both together close the A01 gap.
New Concept · Modelling & Enforcing RBAC
14 minModel: permissions → roles → users
# permissions describe ACTIONS; roles BUNDLE permissions ROLE_PERMISSIONS = { "reader": {"post:read"}, "editor": {"post:read", "post:create", "post:edit"}, "admin": {"post:read", "post:create", "post:edit", "post:delete", "user:manage"}, } def has_permission(role: str, permission: str) -> bool: return permission in ROLE_PERMISSIONS.get(role, set()) # deny if unknown # users carry a role: USERS = {1: {"name": "aisha", "role": "editor"}, 2: {"name": "admin", "role": "admin"}}
Checking permissions (not roles directly) in your code is more flexible: routes ask "does this user have post:delete?" rather than "is this user an admin?", so you can reshuffle roles without touching routes.
Enforce: a deny-by-default decorator
import functools from flask import session, abort def current_role(): uid = session.get("uid") return USERS.get(uid, {}).get("role") # None if not logged in def require_permission(permission: str): def deco(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): role = current_role() if not role or not has_permission(role, permission): abort(403) # DENY BY DEFAULT return fn(*args, **kwargs) return wrapper return deco @app.delete("/post/<int:pid>") @require_permission("post:delete") # only roles with this permission def delete_post(pid): ...
The decorator centralises the check: deny unless the user's role grants the required permission. Centralising matters — a forgotten check then fails closed (no decorator = no access path you intended), not open.
Role checks vs. ownership checks (you need both)
@app.post("/post/<int:pid>/edit") @require_permission("post:edit") # 1) ROLE: editors may edit posts def edit_post(pid): post = db.get_post(pid) # 2) OWNERSHIP: but only YOUR OWN posts (unless admin) if post.author_id != session["uid"] and current_role() != "admin": abort(404) ...
"Editors can edit posts" (role) doesn't mean "this editor can edit that post" (ownership). Without the ownership check you have IDOR (Lesson 28): any editor edits anyone's post. Real access control = role check (can this kind of user do this action?) + ownership/resource check (on this object?). Forget the second and RBAC gives a false sense of security.
Principles
- Least privilege — give each role the minimum permissions it needs; default new users to the lowest role.
- Deny by default — no role/permission ⇒ no access; unknown roles get nothing.
- Server-side only — never trust a role sent by the client (a hidden field, a JWT claim the client could've forged if unverified — Lesson 38).
- Don't let users set their own role — a registration form that accepts
role=adminis instant privilege escalation.
Worked Example · RBAC on a Mini Blog
12 minGoal: a small Flask blog with reader/editor/admin roles, permission-checked routes, and ownership checks — the structured access control that fixes A01 (and the basis of Lesson 41's project).
import functools from flask import Flask, session, abort, request app = Flask(__name__) app.secret_key = "demo" ROLE_PERMISSIONS = { "reader": {"post:read"}, "editor": {"post:read", "post:create", "post:edit"}, "admin": {"post:read", "post:create", "post:edit", "post:delete", "user:manage"}, } USERS = {1: {"name": "aisha", "role": "editor"}, 2: {"name": "admin", "role": "admin"}, 3: {"name": "ben", "role": "reader"}} POSTS = {10: {"author_id": 1, "body": "Aisha's post"}, 11: {"author_id": 2, "body": "Admin's post"}} def role(): return USERS.get(session.get("uid"), {}).get("role") def has(p): return p in ROLE_PERMISSIONS.get(role(), set()) def require(permission): def deco(fn): @functools.wraps(fn) def w(*a, **k): if not has(permission): abort(403) # deny by default return fn(*a, **k) return w return deco def owns(post_id) -> bool: p = POSTS.get(post_id) return p is not None and (p["author_id"] == session.get("uid") or role() == "admin") @app.post("/post/<int:pid>/edit") @require("post:edit") # ROLE check def edit(pid): if not owns(pid): # OWNERSHIP check abort(404) POSTS[pid]["body"] = request.form["body"] return "edited" @app.delete("/post/<int:pid>") @require("post:delete") # only admins have this permission def delete(pid): POSTS.pop(pid, None) return "deleted" @app.post("/admin/users") @require("user:manage") # admin-only def manage_users(): return "user management"
ben (reader) → POST /post/10/edit → 403 (no post:edit permission) aisha (editor) → edit her OWN post 10 → 200 edited aisha (editor) → edit admin's post 11 → 404 (role ok, but not owner) aisha (editor) → DELETE /post/10 → 403 (no post:delete permission) admin → DELETE /post/10 → 200 deleted ben (reader) → POST /admin/users → 403 (no user:manage)
Read the code
The two layers work together exactly as designed. The role layer (@require) stops a reader from editing and an editor from deleting — permission, not identity, gates the action. The ownership layer (owns) stops an editor from editing someone else's post (the IDOR from Lesson 28) while still letting admins override. Permissions are checked (not roles directly), so changing what an editor can do is a one-line edit to ROLE_PERMISSIONS. Deny-by-default means a route with no decorator simply isn't reachable through this scheme. This is production-grade access control.
Try It Yourself
13 minDefine three roles with permission sets and write has_permission(role, perm). Test that a reader can't delete and an admin can, purely from the model.
Add the @require(permission) decorator to a set of routes and confirm each role gets exactly the right 200/403 responses. Verify a logged-out user is denied everything.
Add a registration route. First (wrongly) let it accept a role field from the form and show that a user can register as admin. Then fix it to force the lowest role server-side, and confirm the escalation is blocked.
Hint
# VULNERABLE: trusts client-supplied role → privilege escalation # USERS[new_id] = {"name": form["name"], "role": form["role"]} ✗ # FIXED: role is decided by the SERVER, not the user USERS[new_id] = {"name": form["name"], "role": "reader"} # default lowest
Mini-Challenge · A Permission Matrix & Tester
8 minBuild a tool that prints a role × permission matrix (so you can review the access model at a glance) and an automated test that, for every (role, route) pair, asserts the expected allow/deny. This is how you prove your access control is correct — and catch a permission accidentally granted to the wrong role.
Show a sample solution
ROLE_PERMISSIONS = { "reader": {"post:read"}, "editor": {"post:read", "post:create", "post:edit"}, "admin": {"post:read", "post:create", "post:edit", "post:delete", "user:manage"}, } ALL_PERMS = sorted({p for perms in ROLE_PERMISSIONS.values() for p in perms}) def print_matrix(): print(f"{'permission':14}" + "".join(f"{r:8}" for r in ROLE_PERMISSIONS)) for perm in ALL_PERMS: row = f"{perm:14}" for role, perms in ROLE_PERMISSIONS.items(): row += f"{' ✓' if perm in perms else ' ·':8}" print(row) def test_access(): # expected (role, permission) -> allowed? cases = [("reader", "post:delete", False), ("admin", "post:delete", True), ("editor", "post:edit", True), ("editor", "user:manage", False)] for role, perm, expect in cases: got = perm in ROLE_PERMISSIONS.get(role, set()) assert got == expect, f"FAIL: {role} {perm} expected {expect}" print("all access-control assertions passed ✓") print_matrix(); test_access()
Non-negotiables: a readable role×permission matrix and automated allow/deny assertions for key (role, permission) pairs.
Recap
3 minRBAC structures Lesson 28's "authorize every request": define permissions (actions), bundle into roles, assign roles to users, and enforce with a deny-by-default decorator that checks the required permission. Crucially, combine the role check (can this kind of user do this action?) with an ownership/resource check (on this object?) — RBAC alone leaves IDOR open. Apply least privilege, decide roles server-side (never trust a client-supplied role — that's instant escalation), and prove correctness with a permission matrix + tests. This is the structured fix for the #1 OWASP risk, and the backbone of the next lesson's project.
Vocabulary Card
- RBAC
- Role-Based Access Control — permissions grouped into roles, roles assigned to users.
- permission
- A specific action (e.g.
post:delete) that roles grant. - least privilege
- Each role/user gets the minimum access it needs.
- privilege escalation
- Gaining higher access than intended (e.g. self-assigning the admin role).
Homework
4 minBuild a permission/role model + @require(permission) decorator and apply it to a set of routes with both role and ownership checks. Add the permission matrix + access tests. Fix a deliberate self-assign-role escalation. Write a note: the difference between a role check and an ownership check, with an example where having only one is a vulnerability. (You'll wire this into the L4 blog in Lesson 41.)
Sample · role vs ownership
Role check: "can this KIND of user perform this action at all?"
e.g. only editors/admins may edit posts (require('post:edit')).
Ownership check: "may this user act on THIS specific object?"
e.g. an editor may edit only their OWN post (post.author_id == uid).
Having only a ROLE check → vulnerability: every editor could edit
EVERY post, including other people's — that's IDOR (L8-28). The role
says "editors can edit"; without ownership it doesn't say "their own".
Having only an OWNERSHIP check → vulnerability: a reader who happens
to "own" something could perform an action their role should never
allow (e.g. deleting), because nothing checked the action permission.
You need BOTH. My escalation fix: registration forces role='reader'
server-side; the form's role field is ignored, so nobody self-promotes
to admin. Matrix + tests confirm reader<editor<admin permissions.Non-negotiables: working RBAC with role+ownership checks, matrix+tests, a fixed escalation, and a clear role-vs-ownership explanation.