Role-Based Access Control (RBAC)
The essential playbook for implementing role-based access control (rbac) in your SaaS.
Set up RBAC early if your SaaS has admin actions, team members, support staff, or tenant-scoped permissions. The goal is simple: define roles, map roles to permissions, enforce checks in backend code, and keep authorization separate from UI visibility. For MVPs, start with a small role model and expand only when needed.
Quick Fix / Quick Setup
# Minimal RBAC pattern for Flask/FastAPI-style apps
# Keep permission checks on the server, not only in the frontend.
from enum import Enum
from functools import wraps
from fastapi import HTTPException
class Role(str, Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
SUPPORT = "support"
ROLE_PERMISSIONS = {
Role.OWNER: {"billing.read", "billing.write", "users.read", "users.write", "project.read", "project.write"},
Role.ADMIN: {"users.read", "users.write", "project.read", "project.write"},
Role.MEMBER: {"project.read"},
Role.SUPPORT: {"users.read", "project.read"},
}
def has_permission(user, permission: str) -> bool:
if not user or not getattr(user, "role", None):
return False
return permission in ROLE_PERMISSIONS.get(Role(user.role), set())
# FastAPI dependency example
from fastapi import Depends
def require_permission(permission: str):
def checker(current_user=Depends(get_current_user)):
if not has_permission(current_user, permission):
raise HTTPException(status_code=403, detail="Forbidden")
return current_user
return checker
# Usage
# @app.get('/admin/users')
# def list_users(user=Depends(require_permission('users.read'))):
# ...
# Flask decorator example
# def require_permission_flask(permission):
# def decorator(fn):
# @wraps(fn)
# def wrapper(*args, **kwargs):
# user = get_current_user()
# if not has_permission(user, permission):
# return {"error": "forbidden"}, 403
# return fn(*args, **kwargs)
# return wrapper
# return decoratorStart with 3-5 roles and a fixed permission map. Enforce checks in API handlers and service-layer functions. If you support teams or tenants, scope role checks by organization, not globally.
What’s happening
- RBAC controls what authenticated users can do after login.
- Authentication answers who the user is; authorization answers what the user can access.
- A role is a bundle of permissions such as
project.read,users.write, orbilling.write. - For small SaaS products, role-to-permission mapping is usually enough. Avoid overbuilding attribute-based access control unless your requirements are already complex.
- UI hiding is not authorization. Every protected action must be validated server-side.
- If your app is multi-tenant, permissions must also be checked against the current tenant or organization context.
Step-by-step implementation
1) Define permissions first
Use stable, human-readable permission names.
PERMISSIONS = {
"project.read",
"project.write",
"users.read",
"users.write",
"billing.read",
"billing.write",
}Do not start with role names in route logic. Start with permissions, then map roles to them.
2) Define a minimal role set
For most MVPs:
owneradminmembersupport
Example mapping:
ROLE_PERMISSIONS = {
"owner": {
"billing.read", "billing.write",
"users.read", "users.write",
"project.read", "project.write",
},
"admin": {
"users.read", "users.write",
"project.read", "project.write",
},
"member": {
"project.read",
},
"support": {
"users.read", "project.read",
},
}3) Pick the right data model
Single-tenant app:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'support'))
);Multi-tenant SaaS:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE organization_memberships (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
organization_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'support')),
PRIMARY KEY (user_id, organization_id)
);
CREATE INDEX idx_org_memberships_org_user
ON organization_memberships (organization_id, user_id);If your app has teams or workspaces, prefer tenant-scoped memberships immediately.
4) Resolve current user and tenant on every request
You need both:
- authenticated user
- active organization/tenant
FastAPI example:
from fastapi import Header, HTTPException
def get_current_organization(x_org_id: str | None = Header(default=None)):
if not x_org_id:
raise HTTPException(status_code=400, detail="Missing organization context")
return {"id": int(x_org_id)}Membership lookup:
def get_membership(user_id: int, organization_id: int, db):
return db.execute(
"""
SELECT user_id, organization_id, role
FROM organization_memberships
WHERE user_id = %s AND organization_id = %s
""",
(user_id, organization_id),
).fetchone()5) Create reusable authorization helpers
FastAPI tenant-aware helper:
from fastapi import Depends, HTTPException
def membership_has_permission(membership, permission: str) -> bool:
if not membership:
return False
allowed = ROLE_PERMISSIONS.get(membership["role"], set())
return permission in allowed
def require_org_permission(permission: str):
def checker(
current_user=Depends(get_current_user),
current_org=Depends(get_current_organization),
db=Depends(get_db),
):
membership = get_membership(current_user.id, current_org["id"], db)
if not membership:
raise HTTPException(status_code=403, detail="No organization membership")
if not membership_has_permission(membership, permission):
raise HTTPException(status_code=403, detail="Forbidden")
return {
"user": current_user,
"organization": current_org,
"membership": membership,
}
return checkerUsage:
@app.get("/org/users")
def list_org_users(ctx=Depends(require_org_permission("users.read"))):
return {"ok": True, "org_id": ctx["organization"]["id"]}Flask example:
from functools import wraps
from flask import g, jsonify
def require_org_permission_flask(permission):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
membership = get_membership(g.current_user.id, g.current_org["id"], g.db)
if not membership:
return jsonify({"error": "forbidden"}), 403
if permission not in ROLE_PERMISSIONS.get(membership["role"], set()):
return jsonify({"error": "forbidden"}), 403
g.membership = membership
return fn(*args, **kwargs)
return wrapper
return decorator6) Enforce authorization in route handlers and service layer
Do not rely on route decorators alone for high-risk operations.
Bad:
def delete_organization(org_id, db):
db.execute("DELETE FROM organizations WHERE id = %s", (org_id,))Better:
def delete_organization(org_id, actor_membership, db):
if "billing.write" not in ROLE_PERMISSIONS.get(actor_membership["role"], set()):
raise PermissionError("forbidden")
db.execute("DELETE FROM organizations WHERE id = %s", (org_id,))Sensitive operations to protect explicitly:
- billing changes
- invites
- role changes
- exports
- destructive deletes
- impersonation
7) Scope data access by tenant
Even if the route is protected, queries must still filter by tenant.
Bad:
SELECT * FROM projects WHERE id = $1;Good:
SELECT * FROM projects
WHERE id = $1 AND organization_id = $2;Python example:
def get_project(project_id: int, organization_id: int, db):
return db.execute(
"""
SELECT id, organization_id, name
FROM projects
WHERE id = %s AND organization_id = %s
""",
(project_id, organization_id),
).fetchone()8) Return correct status codes
Use:
401 Unauthorizedfor unauthenticated requests403 Forbiddenfor authenticated users without permission
FastAPI example:
if not current_user:
raise HTTPException(status_code=401, detail="Unauthorized")
if not membership_has_permission(membership, "users.write"):
raise HTTPException(status_code=403, detail="Forbidden")9) Log authorization failures safely
Log enough context to debug without storing secrets.
Example structured log fields:
{
"event": "authorization_denied",
"user_id": 123,
"organization_id": 456,
"permission": "users.write",
"path": "/org/users/789",
"method": "DELETE",
"request_id": "req_abc123"
}Do not log:
- raw tokens
- session secrets
- passwords
- full billing data
10) Add tests for each role and endpoint
Create a role/endpoint matrix and test both allowed and denied cases.
Example pytest pattern:
import pytest
@pytest.mark.parametrize(
"role,expected_status",
[
("owner", 200),
("admin", 200),
("member", 403),
("support", 403),
],
)
def test_users_write_access(client, make_token, role, expected_status):
token = make_token(role=role, org_id=1)
res = client.post(
"/org/users",
headers={"Authorization": f"Bearer {token}", "X-Org-Id": "1"},
json={"email": "new@example.com"},
)
assert res.status_code == expected_status11) Review admin-only and support-only actions separately
Support access should be explicit and narrower than admin access.
Example:
ROLE_PERMISSIONS = {
"owner": {"users.read", "users.write", "billing.read", "billing.write", "project.read", "project.write"},
"admin": {"users.read", "users.write", "project.read", "project.write"},
"member": {"project.read"},
"support": {"users.read", "project.read"},
}Do not reuse admin when you mean limited support access.
Common causes
- Checking permissions only in the frontend and not in backend routes.
- Using a single global role when the app really needs tenant-scoped roles.
- Forgetting to filter database queries by organization or account ID.
- Returning 403 because auth middleware did not load the user correctly.
- Role changes not taking effect because JWT claims or sessions are stale.
- Hardcoding admin checks across many files instead of using centralized permission helpers.
- Allowing background jobs or internal admin tools to bypass authorization rules.
- Confusing 401 and 403 responses, making debugging harder.
- Not covering destructive actions like billing updates, exports, and deletes with explicit permissions.
- Middleware order is wrong, so authorization runs before authentication or tenant resolution.
Debugging tips
Start with direct API verification before checking frontend behavior.
Useful commands
curl -i http://localhost:8000/protected-route
curl -i -H 'Authorization: Bearer <token>' http://localhost:8000/admin/users
curl -i -H 'Cookie: session=<session_cookie>' http://localhost:8000/admin/users
psql "$DATABASE_URL" -c "SELECT id, email, role FROM users WHERE email = 'user@example.com';"
psql "$DATABASE_URL" -c "SELECT user_id, organization_id, role FROM organization_memberships WHERE user_id = 123;"
jwt decode <token>
python -c "from app.auth.rbac import ROLE_PERMISSIONS; print(ROLE_PERMISSIONS)"
grep -R "require_permission\|role ==\|users.write\|billing.write" .
tail -f /var/log/nginx/access.log /var/log/nginx/error.log
docker compose logs -f web
journalctl -u gunicorn -f
pytest -k rbac -qWhat to verify
- Verify the user is authenticated before testing RBAC. Many 403 reports are actually missing sessions or bad tokens.
- Inspect the resolved tenant context. Wrong organization selection often looks like a permission bug.
- Check the exact role loaded from the database or token.
- Print or log the computed permission set for the current request during debugging.
- Confirm middleware order. Auth must run before authorization.
- Test direct API calls with
curlor Postman instead of relying on frontend behavior. - Review stale JWT/session claims after a role change. Users may need token refresh or re-login.
- Check that background tasks and admin endpoints do not bypass tenant filters.
Middleware order example
FastAPI:
app.middleware("http")(request_id_middleware)
app.middleware("http")(auth_middleware)
app.middleware("http")(tenant_middleware)Flask:
@app.before_request
def load_request_context():
g.request_id = get_request_id()
g.current_user = get_current_user()
g.current_org = get_current_org()Auth and tenant resolution must complete before permission checks.
Checklist
- ✓ Permissions are defined centrally.
- ✓ Roles are minimal and documented.
- ✓ Authorization runs on every sensitive backend route.
- ✓ Tenant scoping is enforced in queries.
- ✓
401and403responses are used correctly. - ✓ Role changes take effect immediately or token refresh behavior is documented.
- ✓ Authorization failures are logged.
- ✓ Tests cover each role and critical endpoint.
- ✓ Billing and admin actions are restricted separately.
- ✓ Frontend visibility matches backend permissions, but backend remains the source of truth.
For full launch validation, review SaaS Production Checklist.
Related guides
- Implement User Authentication (Login/Register) for identity and session basics before adding RBAC.
- Protecting Routes and APIs for middleware and endpoint-level enforcement patterns.
- Session Management vs JWT (When to Use What) for deciding how role state is loaded and refreshed.
- SaaS Production Checklist for validating auth, access control, and tenant isolation before launch.
- Structuring a Flask/FastAPI SaaS Project for organizing auth, middleware, and service-layer permission code.
FAQ
What is the simplest RBAC model for an MVP?
Use 3-5 roles mapped to named permissions in code, enforce checks in backend routes and services, and scope roles by organization if your app has teams.
When should I move from role checks to a permissions table?
Move when roles become dynamic, customer-specific, or too numerous to manage safely in code. Until then, a central in-code map is faster and easier to audit.
How should I handle billing permissions?
Treat billing as a separate permission group such as billing.read and billing.write, and usually restrict billing.write to owner or admin only.
Can support staff access customer data?
Only if you explicitly define a support role with limited read permissions and audit logging. Do not reuse admin for support access.
How do I test RBAC properly?
Create a matrix of roles and endpoints, then write positive and negative tests for each sensitive action, including tenant boundary checks and role-change behavior.
Final takeaway
For most MVPs, good RBAC means: a short permission list, a small role map, tenant-scoped membership, and backend enforcement everywhere sensitive.
Keep the model simple at first, but design it so you can move from global roles to organization memberships without rewriting your whole app.
Process Flow
| Role | Read | Write | Delete | Admin |
|---|---|---|---|---|
| Owner | ✓ | ✓ | ✓ | ✓ |
| Admin | ✓ | ✓ | ✓ | ✗ |
| Member | ✓ | ✓ | ✗ | ✗ |
| Support | ✓ | ✗ | ✗ | ✗ |
matrix table of roles vs permissions for owner, admin, member, and support.