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

python
# 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 decorator

Start 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, or billing.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.

python
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:

  • owner
  • admin
  • member
  • support

Example mapping:

python
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:

sql
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:

sql
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:

python
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:

python
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:

python
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 checker

Usage:

python
@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:

python
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 decorator

6) Enforce authorization in route handlers and service layer

Do not rely on route decorators alone for high-risk operations.

Bad:

python
def delete_organization(org_id, db):
    db.execute("DELETE FROM organizations WHERE id = %s", (org_id,))

Better:

python
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:

sql
SELECT * FROM projects WHERE id = $1;

Good:

sql
SELECT * FROM projects
WHERE id = $1 AND organization_id = $2;

Python example:

python
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 Unauthorized for unauthenticated requests
  • 403 Forbidden for authenticated users without permission

FastAPI example:

python
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:

json
{
  "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:

python
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_status

11) Review admin-only and support-only actions separately

Support access should be explicit and narrower than admin access.

Example:

python
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

bash
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 -q

What 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 curl or 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:

python
app.middleware("http")(request_id_middleware)
app.middleware("http")(auth_middleware)
app.middleware("http")(tenant_middleware)

Flask:

python
@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.
  • 401 and 403 responses 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

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.

request
auth
tenant resolution
membership lookup
permission check
data scope
response

Process Flow

RoleReadWriteDeleteAdmin
Owner
Admin
Member
Support

matrix table of roles vs permissions for owner, admin, member, and support.