Role-Based Access Control in Python — Core Concepts

What RBAC solves

Authorization answers “what can this user do?” Authentication tells you who someone is; authorization determines what they’re allowed to do. Without a structured model, authorization logic scatters across the codebase as ad-hoc if user.id == 42 checks that become unmaintainable.

RBAC provides that structure. The core idea: permissions attach to roles, roles attach to users. This indirection layer means you manage permissions at the role level, not the individual user level.

The RBAC model

Three building blocks:

Users — the people (or service accounts) using the system.

Roles — named collections of permissions. Examples: viewer, editor, admin, billing_manager.

Permissions — specific actions on specific resources. Examples: posts:read, posts:write, users:delete, billing:export.

The relationship is: User → has Roles → which grant Permissions.

Role hierarchy

Roles can inherit from each other. An admin role might inherit all editor permissions, which in turn inherits all viewer permissions. This avoids duplicating permission assignments:

viewer    → posts:read, comments:read
editor    → (inherits viewer) + posts:write, comments:write
admin     → (inherits editor) + users:manage, settings:edit

How it works in practice

A typical RBAC check follows this flow:

  1. User authenticates (login, JWT, session)
  2. Application loads the user’s roles (from database, token claims, or cache)
  3. Application determines what permission is needed for the requested action
  4. Application checks if any of the user’s roles grant that permission
  5. Allow or deny
class RBACChecker:
    def __init__(self):
        self.role_permissions = {
            "viewer": {"posts:read", "comments:read"},
            "editor": {"posts:read", "posts:write", "comments:read", "comments:write"},
            "admin": {"posts:read", "posts:write", "posts:delete",
                      "comments:read", "comments:write", "comments:delete",
                      "users:manage"},
        }

    def has_permission(self, user_roles: list[str], permission: str) -> bool:
        for role in user_roles:
            if permission in self.role_permissions.get(role, set()):
                return True
        return False

Database schema

The standard RBAC schema uses three tables plus two join tables:

users — id, email, name

roles — id, name, description

permissions — id, name (like posts:write)

user_roles — user_id, role_id (many-to-many)

role_permissions — role_id, permission_id (many-to-many)

This design lets you query a user’s effective permissions with two joins, and makes it easy to add or remove permissions from roles without touching individual users.

RBAC in web frameworks

Most Python web frameworks have RBAC patterns:

Django — built-in Group model maps to roles, Permission model is auto-generated from models. The @permission_required decorator handles view-level checks.

FlaskFlask-Principal or Flask-Security add role and permission management. Custom decorators are common.

FastAPI — dependency injection makes RBAC clean. Create a dependency that checks permissions and inject it into route definitions.

Common misconception

“RBAC is enough for all authorization needs.” RBAC works well when permissions depend on the user’s job function. It struggles when permissions depend on the data itself — for example, “editors can only edit posts in their department” or “users can only see their own orders.” Those data-dependent rules need attribute-based access control (ABAC) or ownership checks layered on top of RBAC.

The one thing to remember: RBAC creates a clean abstraction layer — permissions live on roles, roles live on users — so authorization scales from 10 users to 10,000 without individual permission management becoming a nightmare.

pythonsecurityauthorizationweb

See Also