14.2. Role-Based Access Control
Table of Contents
Why Role-Based Access Control Matters
When your application has more than one type of user, you must decide who can do what. For example, an admin can delete any post, but a regular user can only delete their own posts. Role-Based Access Control, usually called RBAC, is a simple and very common way to solve this.
In RBAC you do not assign permissions directly to users. Instead, you create roles, such as admin or editor, and assign permissions to those roles. Then you assign roles to users. This keeps your authorization rules easier to understand and easier to change.
RBAC is especially useful in backend systems because:
- It centralizes access rules in one place.
- It makes it easy to add new user types without rewriting your code.
- It matches how many organizations think about access, for example "managers", "staff", "customers".
Key idea: In RBAC, users get permissions through their roles, not directly.
Core RBAC Concepts
Users, Roles, and Permissions
RBAC is built from three main concepts.
| Concept | Description | Example values |
|---|---|---|
| User | Someone or something that uses your system | user_id = 42, email = "alice@example.com" |
| Role | A named collection of permissions | "admin", "editor", "customer" |
| Permission | A specific action on a specific resource | "post:read", "post:create", "user:delete" |
Common relationships:
- A user can have multiple roles.
- A role can have multiple permissions.
- A permission can be assigned to multiple roles.
So you have two important many-to-many relationships:
- Users β Roles
- Roles β Permissions
You almost never want Users β Permissions directly, because that becomes hard to manage.
Example: Blog Application
Imagine a blog backend. You might define:
- Roles:
adminauthorreader- Permissions:
post:createpost:edit:anypost:edit:ownpost:delete:anypost:delete:ownpost:read:anyuser:manage
Connect roles to permissions:
| Role | Permissions |
|---|---|
| admin | post:* (all post permissions), user:manage |
| author | post:create, post:edit:own, post:delete:own, post:read:any |
| reader | post:read:any |
Then assign roles to users:
| User | Roles |
|---|---|
| Alice (id 1) | admin |
| Bob (id 2) | author |
| Carol (id 3) | reader |
| Dave (id 4) | author, reader |
Now you can decide if Bob can delete a post by checking: Does Bob have a role that includes the permission post:delete:own or post:delete:any?
Roles vs Permissions
Why Not Just Use Roles Everywhere?
Roles are simple to think about, but too many roles can create problems.
Imagine an e-commerce system. You could try to encode everything into roles:
product_viewerproduct_editorinventory_managerorder_viewerorder_managersupport_agentsupport_managerfinance_viewerfinance_manager
Soon you might need combinations like:
product_editor_and_order_managersupport_and_finance_manager
This quickly grows and is hard to maintain.
A better approach:
- Keep roles high level, such as
admin,manager,staff,customer. - Keep permissions fine grained, such as
order:refund,product:edit,ticket:assign.
Then attach many permissions to one role.
Rule: Use few, stable roles and many, detailed permissions.
Do not create a new role for every tiny difference in access.
Mapping Roles to Permissions in Code
In a simple Python backend you might keep a dictionary:
ROLE_PERMISSIONS = {
"admin": {
"user:read",
"user:write",
"post:read",
"post:create",
"post:edit:any",
"post:delete:any",
},
"author": {
"post:read",
"post:create",
"post:edit:own",
"post:delete:own",
},
"reader": {
"post:read",
},
}Then the effective permissions for a user with multiple roles is the union of all their role permissions.
def get_user_permissions(user_roles: list[str]) -> set[str]:
permissions: set[str] = set()
for role in user_roles:
permissions |= ROLE_PERMISSIONS.get(role, set())
return permissions
If the user has roles ["author", "reader"], they get all permissions from both roles.
Simple RBAC Data Models
In a real backend you usually store roles and permissions in a database. Here is a common relational model.
RBAC Tables
You can represent RBAC with these tables:
| Table | Purpose |
|---|---|
users | Stores user accounts |
roles | Stores available roles |
permissions | Stores available permissions |
user_roles | Many-to-many link: which roles a user has |
role_permissions | Many-to-many link: which perms a role has |
Example schemas in SQL-like form:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL -- hashed
);
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name TEXT UNIQUE NOT NULL
);
CREATE TABLE permissions (
id SERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL -- e.g. 'post:create'
);
CREATE TABLE user_roles (
user_id INT REFERENCES users(id),
role_id INT REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);
CREATE TABLE role_permissions (
role_id INT REFERENCES roles(id),
permission_id INT REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
In a beginner backend project you might skip the permissions table and only have:
rolesuser_roles
and hard-code what each role can do in the application code. This is still RBAC, only simpler.
Example Data
Insert some roles:
INSERT INTO roles (name) VALUES
('admin'),
('author'),
('reader');Insert some permissions:
INSERT INTO permissions (code) VALUES
('post:read'),
('post:create'),
('post:edit:any'),
('post:edit:own'),
('post:delete:any'),
('post:delete:own'),
('user:manage');
Connect admin to several permissions:
-- Get role id and permission ids (usually done by code)
-- Example link statements:
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r, permissions p
WHERE r.name = 'admin'
AND p.code IN (
'post:read',
'post:create',
'post:edit:any',
'post:delete:any',
'user:manage'
);
Assign the author role to user id 2:
INSERT INTO user_roles (user_id, role_id)
SELECT 2, r.id FROM roles r WHERE r.name = 'author';Your backend logic can now query which permissions a user has, based on their roles.
Enforcing RBAC in API Endpoints
Most of the time you will enforce roles at the API layer. The general pattern is:
- Authenticate the request and get the user identity.
- Load user roles from the database or from the token.
- Decide if the user is allowed to perform this action.
- If not allowed, return
403 Forbidden.
Endpoint-Level Role Checks
A simple example in a Python style pseudocode:
from fastapi import Depends, HTTPException, status
def require_roles(*required_roles: str):
def wrapper(user = Depends(get_current_user)):
user_roles = set(user.roles) # e.g. ["author", "reader"]
if not user_roles.intersection(required_roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient role"
)
return user
return wrapperUse it in an endpoint:
@app.get("/admin/dashboard")
def read_admin_dashboard(user = Depends(require_roles("admin"))):
return {"message": "Hello admin"}
Only users with the admin role can access /admin/dashboard.
Permission-Level Checks
Sometimes roles are too broad. You might want to check for a specific permission, like post:delete:any.
You can create a similar helper:
def require_permission(permission: str):
def wrapper(user = Depends(get_current_user)):
if permission not in user.permissions:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Missing permission"
)
return user
return wrapperThen:
@app.delete("/posts/{post_id}")
def delete_post(post_id: int, user = Depends(require_permission("post:delete:any"))):
# only allowed if user has that permission
...
You can also combine role and permission checks, for example allow admin or anyone with post:delete:any.
Rule: Authorization checks belong in every endpoint that protects sensitive data or actions.
Do not rely only on frontend checks such as hiding buttons.
Ownership and RBAC
RBAC alone does not solve resource ownership. For example, an author should be able to edit their own posts, but not posts from other authors. You must combine RBAC with ownership checks.
A common pattern:
- Check that the user has a role or permission like
post:edit:own. - Load the resource from the database.
- Check that
resource.owner_id == user.id. - If not, return
403 Forbidden.
Example:
@app.put("/posts/{post_id}")
def update_post(
post_id: int,
data: PostUpdate,
user = Depends(get_current_user),
):
post = get_post_by_id(post_id)
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
# Admins can edit any post
if "admin" in user.roles:
return update_post_in_db(post, data)
# Authors can only edit their own posts
if "author" in user.roles and post.author_id == user.id:
return update_post_in_db(post, data)
raise HTTPException(status_code=403, detail="Not allowed to edit this post")This mixes:
- Role check (
adminorauthor). - Ownership check (
post.author_id == user.id).
You can refactor these checks into helper functions to keep code clean.
Designing Roles for Real Applications
Start from Real-World Responsibilities
When designing roles, think about how people actually work with your system.
For example, in an online store:
- Customer
- View products.
- Place orders.
- View their own orders.
- Support Agent
- View any order.
- Create support tickets.
- Update ticket status.
- Manager
- View revenue reports.
- Manage discounts.
- Manage staff accounts.
From this you can design roles:
customersupport_agentsupport_managerstore_manageradmin(if you really need a superuser)
Then define permissions and link them.
Keep Roles Stable, Keep Permissions Flexible
Changing roles later is painful because they are used in:
- Database records.
- Access control checks in code.
- External systems like admin panels.
Permissions are easier to add and change. For example:
- Today
support_agenthasticket:assign. - Tomorrow you add a new permission
ticket:closeand give it only tosupport_manager.
You usually do not need a new role for every change, just adjust role-to-permission mappings.
Example Role Set for a Typical SaaS Application
| Role | Typical Permissions |
|---|---|
| admin | manage users, all data, all settings |
| organization_owner | manage org, billing, invite members, all org data |
| organization_admin | manage org members and settings, not billing |
| member | standard app features |
| readonly | read-only access to data |
Then you define many permissions like:
project:create,project:read,project:update,project:deletebilling:view,billing:updateuser:invite,user:remove
and assign them to the roles above.
Practical Examples of RBAC Checks
Below are some typical patterns that appear in a backend with RBAC.
Protecting an Admin-Only Endpoint
@app.post("/admin/users/{user_id}/ban")
def ban_user(user_id: int, user = Depends(get_current_user)):
if "admin" not in user.roles:
raise HTTPException(status_code=403, detail="Admin only")
# perform ban
...Allowing Multiple Roles
You might have actions that both admin and moderator can do.
def has_any_role(user, roles: list[str]) -> bool:
return bool(set(user.roles).intersection(roles))
@app.delete("/comments/{comment_id}")
def delete_comment(comment_id: int, user = Depends(get_current_user)):
if not has_any_role(user, ["admin", "moderator"]):
raise HTTPException(status_code=403, detail="Moderator or admin required")
...Combination of Own vs Any
You can allow:
- Admins to delete any comment.
- Users to delete their own comments.
@app.delete("/comments/{comment_id}")
def delete_comment(comment_id: int, user = Depends(get_current_user)):
comment = get_comment_by_id(comment_id)
if comment is None:
raise HTTPException(status_code=404, detail="Comment not found")
if "admin" in user.roles:
return delete_comment_in_db(comment_id)
if comment.author_id == user.id:
return delete_comment_in_db(comment_id)
raise HTTPException(status_code=403, detail="Not allowed to delete this comment")
Here admin is handled as a special powerful role, and others are restricted by ownership.
Simple Strategies for Beginners
If you are building your first backend, you can start with a very simple RBAC approach.
Strategy 1: Single Role Field on User
Add a role column in the users table:
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';Allowed values might be:
useradminmoderator
In code, you check this field directly:
def require_admin(user = Depends(get_current_user)):
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only")
return userThis is enough for many small projects.
Strategy 2: User β Role Many-to-Many
If you need multiple roles per user, create roles and user_roles tables as shown earlier. Your user object then has a roles: list[str] field.
You can still keep actual permissions hard-coded in dictionaries or code, and only store roles in the database.
Strategy 3: Full RBAC with Permissions
When your application grows, you can add:
permissionsrole_permissions
Then load the complete permission set into memory or compute it when a user logs in, and include those permissions in the authentication token or in the session.
Common Pitfalls and How to Avoid Them
Pitfall 1: Putting Business Logic in the Frontend Only
If you rely on the frontend to hide or show buttons based on roles, but do not perform checks in the backend, a user can still send forbidden requests using tools like Postman.
Fix: Always enforce RBAC in the backend, in controllers or endpoint functions.
Pitfall 2: Hard-Coding Too Much in Endpoints
If every endpoint has its own custom role checks, your code will become messy.
Fix: Use reusable helpers, decorators, or dependency functions like require_roles or require_permission.
Pitfall 3: Overusing the Admin Role
It is easy to grant admin to testers or developers for convenience, then forget to remove it. If an admin account is compromised, the damage is very large.
Fix:
- Limit the number of admin users.
- Add more specific roles instead of using admin for everything.
- Consider logging all admin actions.
Pitfall 4: Not Thinking About Ownership
Just checking the role is often not enough. For example, an author should not edit posts of another author.
Fix: Always ask, "Is this action global or resource-specific?" If resource-specific, add ownership checks.
Pitfall 5: Inconsistent Role Names
Using unclear names like power_user, basic, or standard can confuse everyone.
Fix: Use role names that clearly describe responsibilities, for example admin, moderator, support_agent, customer.
Summary
Role-Based Access Control is a core concept in backend development. It helps you answer the question "Can this user do this action?" in a structured way.
Remember:
- Users get permissions through roles, not directly.
- Use a small number of clear roles, and many precise permissions.
- Store roles and role assignments in your database.
- Enforce role and ownership checks in every protected API endpoint.
- Start with simple per-user roles, and grow to full RBAC when needed.
With a good RBAC design, your backend becomes safer, easier to maintain, and easier to extend when new user types and features are added.
Views: 4
KAHIBARO