green snake
Photo by Pixabay on Pexels.com
Table of Contents

Introduction to RBAC/ABAC Authorization Management in FastAPI: A Practical Guide to Designing Secure Authorization with Roles, Attributes, and Policies


Summary

  • Authentication is the mechanism for confirming who a person is, while authorization is the mechanism for deciding what that person is allowed to do. In real-world FastAPI development, authorization design has a major impact on both application security and maintainability.
  • In small applications, it is practical to begin with RBAC (Role-Based Access Control). Grouping permissions into roles such as admin, editor, and viewer makes both implementation and operations easier to understand.
  • However, in SaaS and multi-tenant environments, role-based logic alone often becomes insufficient. That is where ABAC (Attribute-Based Access Control) becomes important, by combining conditions such as tenant membership, ownership, publication status, and plan tier.
  • In FastAPI, a clean approach is to use Depends and Security to resolve the authenticated user, tenant, and target resource through dependency functions, and then centralize authorization decisions in policy functions.
  • In this article, we will walk through the differences between RBAC and ABAC, implementation patterns in FastAPI, how to combine them with multi-tenancy, how to connect them with audit logs, and how to test them, step by step and carefully.

Who Will Benefit from Reading This

Individual developers and learners

This is for people who have implemented login functionality, but are unsure how to write authorization rules such as “only administrators can edit” or “only the user themselves can delete their account.”
At first, something like if current_user.role == "admin" may seem to work fine, but once features grow, that style tends to become messy. This article will help you move from that stage toward a more organized structure.

Backend engineers in small teams

This is for people building admin panels, internal tools, or SaaS APIs with FastAPI and feeling that “role checks are scattered everywhere and feel risky” or “it is hard to see the impact of permission changes.”
The structure here is designed to answer practical questions such as how far RBAC alone can take you, when ABAC should be introduced, and how to divide responsibilities between service layers and dependency functions.

SaaS teams and startups

This is for teams in multi-tenant environments where tenant-level roles, ownership restrictions, plan-based feature limits, audit logs, and similar concerns have made it clear that “a simple admin check is no longer enough.”
If authorization rules are added in an ad hoc way, they become very difficult to fix later. Organizing early on where authorization decisions belong makes the system more resilient to future complexity.


Accessibility Evaluation

  • The article starts by presenting the overall picture, then proceeds in the order of “RBAC basics,” “introducing ABAC,” “dependencies in FastAPI,” “policy functions,” “multi-tenancy,” and “testing and audit.” This makes it easy to understand even if you only read the sections you need.
  • Technical terms are briefly explained when they first appear, and then the same wording is used consistently afterward. This is intended to reduce cognitive load.
  • Code examples are kept short and split up so that each block shows only one responsibility. This makes them easier to follow visually.
  • Headings are placed frequently so that the key point of each section is clear. The target level is approximately WCAG AA.

1. Why Is It Dangerous to Postpone Authorization?

In FastAPI, as in any framework, the early stage of application development often feels like it reaches a big milestone once “users can log in.”
But what really matters next is how you control what logged-in users are allowed to do.

A common early implementation looks like this:

if current_user.role != "admin":
    raise HTTPException(status_code=403, detail="forbidden")

At first, this feels sufficient, but as features increase, the following kinds of problems start to appear:

  • The checks become inconsistent across routers
  • Once roles other than admin are added, there are too many places to update
  • Conditions such as “only the owner can edit” or “only an admin in the same tenant can do this” begin to mix together
  • The permission names understood by the frontend and backend drift apart
  • It becomes hard to leave clear audit logs showing why access was denied

In other words, authorization is not just an if statement. It is a design concern.
Once this part is organized properly, not only security but also code readability and ease of change improve substantially.


2. What Is RBAC? Managing Access Through Roles

RBAC stands for Role-Based Access Control, and it is the idea of deciding permissions based on a person’s role.

For example, in an article management application, you might think of it like this:

  • admin: can do everything
  • editor: can create, edit, and publish articles
  • viewer: can only view

The nice thing about this approach is that it is easy for people to understand.
If you say “this person is an editor,” that is easy to explain within a team and easy to display in the UI.

2.1 Cases Where RBAC Works Well

RBAC is especially strong in situations like these:

  • Internal admin panels
  • Small to medium-sized management APIs
  • Applications where permission patterns are relatively stable
  • SaaS products where roles within each tenant are clearly defined

As a first authorization model, it is very approachable.

2.2 Situations Where RBAC Alone Stops Being Enough

However, in real work, conditions like the following often appear:

  • Even an editor can only edit articles they created themselves
  • A viewer can still see published content
  • Even an admin cannot view data from a tenant they do not belong to
  • Some features are unavailable in tenants on a free plan

As more of these checks based on attributes other than role appear, RBAC alone becomes harder to use.
That is where ABAC comes in.


3. What Is ABAC? Making Fine-Grained Decisions with Attributes

ABAC stands for Attribute-Based Access Control, and it is the idea of deciding authorization based on attributes.

Here, attributes can include information such as:

  • User attributes
    • user_id, role, department, plan
  • Resource attributes
    • owner_id, tenant_id, status, visibility
  • Environment attributes
    • time, IP address, client environment
  • Tenant attributes
    • subscribed plan, options, suspension state

For example, these are typical ABAC-style decisions:

  • “Editing is allowed if the article’s owner_id matches the current user’s user_id
  • “Export is allowed if the tenant’s plan is pro or higher”
  • “A resource can be viewed even without login if it is marked public

So ABAC is not just about asking “what role does this person have?” but also “what is the relationship between this person and the target?”

3.1 RBAC and ABAC Are Not Opposed — They Are Usually Combined

In practice, RBAC and ABAC are often used together rather than choosing only one.

For example:

  • Use RBAC to decide the broad outline
    • A viewer cannot access update APIs
  • Use ABAC to decide the fine-grained conditions
    • An editor can only edit projects in their own tenant

This two-layer approach makes it easier to achieve both clarity and flexibility.


4. The Basic FastAPI Approach: Push Authorization into Dependency Functions and Policy Functions

In FastAPI, authorization logic is usually easier to understand when it is pushed into dependency functions and policy functions rather than written directly inside routers.

If we separate responsibilities, they look like this:

  • Authentication dependency
    • gets the current user
  • Tenant dependency
    • resolves the current tenant_id
  • Resource dependency
    • gets the target project or article
  • Policy function
    • decides whether “this user is allowed to perform this action”

With this structure, routers become easier to read because you can clearly see what is being used for authorization, and the code is easier to test as well.


5. A Base Model for Users, Tenants, and Roles

Let us start with the smallest model that still makes the concepts easy to understand.

# app/models/auth.py
from pydantic import BaseModel

class CurrentUser(BaseModel):
    user_id: int
    email: str
    global_role: str | None = None
    tenant_ids: list[int] = []
    active_tenant_id: int | None = None

Here, we assume the following simple structure:

  • global_role represents system-wide authority such as a super-admin
  • tenant_ids is the list of tenants the user belongs to
  • active_tenant_id is the currently selected tenant

Then we manage tenant-specific roles separately.

# app/models/membership.py
from pydantic import BaseModel
from typing import Literal

TenantRole = Literal["owner", "admin", "member", "viewer"]

class TenantMembership(BaseModel):
    user_id: int
    tenant_id: int
    role: TenantRole

This allows us to think separately about “global administrators” and “roles inside each tenant.”


6. Resolve the Authenticated User and Tenant Through Dependencies

6.1 Return the current user

In practice, JWT is common, but here we simplify for explanation.

# app/deps/auth.py
from fastapi import Depends
from app.models.auth import CurrentUser

def get_current_user() -> CurrentUser:
    return CurrentUser(
        user_id=1,
        email="hanako@example.com",
        global_role=None,
        tenant_ids=[10, 20],
        active_tenant_id=10,
    )

6.2 Resolve the current tenant_id

# app/deps/tenant.py
from fastapi import Depends, Header, HTTPException, status
from app.deps.auth import get_current_user
from app.models.auth import CurrentUser

def get_current_tenant_id(
    current_user: CurrentUser = Depends(get_current_user),
    x_tenant_id: int | None = Header(default=None),
) -> int:
    tenant_id = x_tenant_id or current_user.active_tenant_id

    if tenant_id is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="tenant is not selected",
        )

    if tenant_id not in current_user.tenant_ids:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="tenant access is forbidden",
        )

    return tenant_id

The important point here is: never trust a tenant_id from a header or path by itself.
Always check whether the user actually belongs to that tenant.


7. Minimal RBAC Implementation: Turn Role Checks into Dependencies

Let us begin with a simple case where RBAC alone is enough.
For example, suppose only tenant administrators are allowed to invite members.

7.1 Function to get the tenant role

# app/deps/membership.py
from fastapi import Depends, HTTPException, status
from app.deps.auth import get_current_user
from app.deps.tenant import get_current_tenant_id
from app.models.auth import CurrentUser
from app.models.membership import TenantMembership

def get_current_membership(
    current_user: CurrentUser = Depends(get_current_user),
    tenant_id: int = Depends(get_current_tenant_id),
) -> TenantMembership:
    # In practice, fetch from the DB
    if current_user.user_id == 1 and tenant_id == 10:
        return TenantMembership(user_id=1, tenant_id=10, role="admin")

    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail="membership not found",
    )

7.2 Dependency requiring a specific role

# app/deps/permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.membership import get_current_membership
from app.models.membership import TenantMembership

def require_tenant_admin(
    membership: TenantMembership = Depends(get_current_membership),
) -> TenantMembership:
    if membership.role not in {"owner", "admin"}:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="admin role required",
        )
    return membership

7.3 Use it in the router

# app/api/v1/routers/members.py
from fastapi import APIRouter, Depends

from app.deps.permissions import require_tenant_admin

router = APIRouter(prefix="/members", tags=["members"])

@router.post("")
def invite_member(
    membership = Depends(require_tenant_admin),
):
    return {"status": "invited", "tenant_id": membership.tenant_id}

With this structure, the router clearly expresses the intent that “administrator permission is required.”


8. Basic ABAC Implementation: Decide Based on Ownership and Resource Attributes

Now let us move to a case where RBAC alone is not enough.
For example: even within the same tenant, members should only be able to edit projects they created themselves.

8.1 Example resource model

# app/models/project.py
from pydantic import BaseModel
from typing import Literal

class Project(BaseModel):
    id: int
    tenant_id: int
    owner_id: int
    name: str
    visibility: Literal["private", "public"] = "private"

8.2 Dependency to fetch the target resource

# app/deps/project.py
from fastapi import Depends, HTTPException, status
from app.deps.tenant import get_current_tenant_id
from app.models.project import Project

def get_project(project_id: int, tenant_id: int = Depends(get_current_tenant_id)) -> Project:
    # In practice, fetch from the DB with tenant_id as a filter
    if project_id == 1 and tenant_id == 10:
        return Project(id=1, tenant_id=10, owner_id=1, name="Project A", visibility="private")

    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="project not found",
    )

The key point is that the resource is already fetched not by project_id alone, but together with tenant_id.

8.3 Decide in a policy function

# app/policies/project_policy.py
from app.models.auth import CurrentUser
from app.models.membership import TenantMembership
from app.models.project import Project

def can_edit_project(
    current_user: CurrentUser,
    membership: TenantMembership,
    project: Project,
) -> bool:
    if membership.role in {"owner", "admin"}:
        return True

    if membership.role == "member" and project.owner_id == current_user.user_id:
        return True

    return False

8.4 Wrap it as a dependency

# app/deps/permissions.py
from fastapi import Depends, HTTPException, status
from app.deps.auth import get_current_user
from app.deps.membership import get_current_membership
from app.deps.project import get_project
from app.policies.project_policy import can_edit_project

def require_project_edit_permission(
    current_user = Depends(get_current_user),
    membership = Depends(get_current_membership),
    project = Depends(get_project),
):
    if not can_edit_project(current_user, membership, project):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="project edit is forbidden",
        )
    return project

In this form, the authorization logic itself stays as a pure function that is easy to test, while the FastAPI-dependent part remains thin.


9. Common Types of Attributes Used in ABAC

It becomes easier to design ABAC if you first organize the kinds of attributes that commonly appear in practice.

9.1 User attributes

  • user_id
  • global_role
  • email_verified
  • is_active
  • department
  • plan

9.2 Tenant attributes

  • tenant_id
  • plan (free / pro / enterprise)
  • status (active / suspended)
  • feature_flags

9.3 Resource attributes

  • owner_id
  • tenant_id
  • status (draft / published / archived)
  • visibility (private / internal / public)

9.4 Environment attributes

  • access time
  • IP address
  • region
  • client type

You do not need to inspect all of these from the start, but it helps to keep this perspective: beyond role, what else should be checked to make access safe?


10. Treat Plan Restrictions as Part of Authorization Too

In SaaS, feature restrictions are often tied to subscription plans.
It is often cleaner to treat this as a kind of ABAC as well.

10.1 Decision based on tenant plan

# app/models/tenant.py
from pydantic import BaseModel
from typing import Literal

class Tenant(BaseModel):
    id: int
    name: str
    plan: Literal["free", "pro", "enterprise"]
# app/policies/export_policy.py
from app.models.tenant import Tenant
from app.models.membership import TenantMembership

def can_export_csv(tenant: Tenant, membership: TenantMembership) -> bool:
    if tenant.plan not in {"pro", "enterprise"}:
        return False
    if membership.role not in {"owner", "admin", "member"}:
        return False
    return True

This way, both role and plan are handled explicitly.
Later, when rules such as “only enterprise tenants get extended audit log retention” are added, the logic stays easier to reason about.


11. Do Not Put Too Much in Routers: Centralize Policies in a Service Layer or Dedicated Module

Once you start writing authorization as raw if statements in each router, the code becomes very hard to read a few months later.
A recommended division looks like this:

  • Router
    • lines up dependency functions
  • Policy functions
    • express authorization rules
  • Service layer
    • handles important business rules together with authorization

For example, “publishing a project” may require not only edit permission, but also a business rule such as “the project must not already be archived.”
In such a case, thinking about authorization together with the service layer is more natural than handling everything in the router.


12. Combine with Audit Logs: Record Denied Actions Too

This connects to the earlier audit log design topic as well, but denied authorization attempts are important evidence too.
Especially events like the following are worth auditing:

  • A user tried to access data belonging to another tenant
  • A general member attempted an action requiring admin privileges
  • A feature unavailable under the current plan was called
  • An API key tried to access a forbidden endpoint

For example, when authorization fails, you might record an audit event like this:

detail={
    "reason": "role_not_allowed",
    "required": ["owner", "admin"],
    "actual": "viewer",
}

Records like this make customer support and security monitoring much easier.


13. Common Failure Patterns

13.1 Deciding everything with only an is_admin flag

This feels convenient at first, but it tends to collapse once tenant roles and ownership checks are introduced.

13.2 Fetching only by resource_id without checking tenant_id

This is one of the most dangerous mistakes in multi-tenant systems.
Always include tenant_id in the query condition.

13.3 Authorization logic scattered across routers

This makes it very hard to see the impact of changes.
It is better to move the logic into policy functions and dependencies.

13.4 Feeling satisfied with frontend-only visibility control

Hiding a button does not matter if the API can still be called directly.
Final authorization control must always happen in the backend.

13.5 Not documenting the authorization design

If role names and permissions exist only in spoken conversation, implementation and operations are likely to drift apart.
It is much safer to align them through OpenAPI, internal documentation, and admin UI terminology.


14. Testing Strategy: Protect Authorization on the Assumption That It Is Easy to Break

Authorization is an area that tends to break whenever new features are added.
So it is important not only to write success-case tests, but also to write solid tests for cases that should be denied.

14.1 The minimum tests you should want

  • admin can update
  • viewer cannot update
  • member can update only what they own
  • Resources in another tenant cannot be accessed
  • Export is not allowed on the free plan
  • Audit logs are recorded on authorization failure

14.2 Example unit test for a policy function

# tests/test_project_policy.py
from app.models.auth import CurrentUser
from app.models.membership import TenantMembership
from app.models.project import Project
from app.policies.project_policy import can_edit_project

def test_admin_can_edit_any_project():
    user = CurrentUser(user_id=1, email="a@example.com", tenant_ids=[10], active_tenant_id=10)
    membership = TenantMembership(user_id=1, tenant_id=10, role="admin")
    project = Project(id=1, tenant_id=10, owner_id=2, name="P", visibility="private")

    assert can_edit_project(user, membership, project) is True

def test_member_can_edit_own_project_only():
    user = CurrentUser(user_id=1, email="a@example.com", tenant_ids=[10], active_tenant_id=10)
    membership = TenantMembership(user_id=1, tenant_id=10, role="member")
    own_project = Project(id=1, tenant_id=10, owner_id=1, name="Own", visibility="private")
    other_project = Project(id=2, tenant_id=10, owner_id=2, name="Other", visibility="private")

    assert can_edit_project(user, membership, own_project) is True
    assert can_edit_project(user, membership, other_project) is False

When the core authorization logic is kept as pure functions like this, it becomes very easy to test.


15. Also Think About Alignment with OpenAPI and the Frontend

Authorization is not only a backend concern.
The frontend also needs to know what the user is allowed to do.

Two common patterns are:

  • Include roles or a list of permissions in the response from GET /me
  • Include plan information and enabled feature flags in a tenant info API

For example:

{
  "user_id": 1,
  "tenant_id": 10,
  "role": "admin",
  "permissions": [
    "project.read",
    "project.create",
    "project.update",
    "member.invite"
  ]
}

A response like this makes UI control much easier on the frontend.
However, this information should only be used as a guide for display control. Final authorization decisions must always be made by the backend.


16. Adoption Roadmap

Individual developers and learners

  1. Start with RBAC
  2. Create dependency functions like require_admin
  3. Once ownership checks become necessary, move them into policy functions
  4. Add 403 tests for the main APIs

Engineers in small teams

  1. Create a table of current roles and permissions
  2. Review authorization logic currently written directly in routers
  3. Move it step by step into dependencies and policy functions
  4. Record denial reasons in audit logs
  5. Improve OpenAPI and frontend-facing /me responses

SaaS teams and startups

  1. Separate global roles from tenant-level roles
  2. Make tenant_id a required condition in all major data access paths
  3. Identify the ABAC attributes you need, such as ownership, plan, and status
  4. Centralize authorization policies in code
  5. Add contract tests and permission tests to CI to prevent breaking changes

Reference Links


Conclusion

  • RBAC is easy to start with and is a very practical first step for authorization design in FastAPI.
  • However, once multi-tenancy, ownership rules, and plan-based limits enter the picture, ABAC becomes necessary.
  • In FastAPI, a clean structure is to push authentication, tenant resolution, and resource resolution into dependency functions, and centralize the actual authorization decisions in policy functions. This improves clarity and testability.
  • Authorization should be designed as a backend responsibility, not just display control, and cases that should be denied must also be protected by tests.
  • Rather than trying to build the perfect system from the start, it is more realistic to first establish a pattern with RBAC and then add ABAC where needed.

A natural next article in this sequence would be something like “Designing Plans, Billing, and Feature Restrictions for SaaS” or “Design Patterns for Internal Admin APIs Built with FastAPI.”

By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)