DEV Community

Nikita Rybalchenko
Nikita Rybalchenko

Posted on

FastAPI Authorization Without Middleware: Decorator-Based Casbin Integration

Authorization in FastAPI tends to start clean and quietly become a mess. One Depends(require_admin), then Depends(require_editor), then Depends(require_editor_or_admin_but_only_on_weekdays)... and three months later nobody wants to touch the permissions file.

I've been there. So I wrote casbin-fastapi-decorator — a library that brings Casbin into FastAPI with zero middleware, using a decorator factory pattern. One decorator above each route. No boilerplate in function signatures.

The Problem

Standard FastAPI authorization ends up looking like this:

async def require_admin(user = Depends(get_current_user)):
    if user.role != "admin":
        raise HTTPException(403, "Forbidden")
    return user

async def require_editor(user = Depends(get_current_user)):
    if user.role not in ("admin", "editor"):
        raise HTTPException(403, "Forbidden")
    return user

@app.get("/articles")
async def list_articles(user = Depends(require_editor)):
    ...

@app.delete("/articles/{id}")
async def delete_article(id: int, user = Depends(require_admin)):
    ...
Enter fullscreen mode Exit fullscreen mode

It works — until it doesn't. Adding a new role means hunting through every dependency function. Want to change what editors can do? Same hunt. No single place that says "here is who can do what."

The Solution

casbin-fastapi-decorator uses fastapi-decorators under the hood — no middleware required. You configure a PermissionGuard once and use it as a decorator factory:

import casbin
from fastapi import FastAPI, HTTPException
from casbin_fastapi_decorator import PermissionGuard

async def get_current_user() -> dict:
    return {"sub": "alice", "role": "admin"}

async def get_enforcer() -> casbin.Enforcer:
    return casbin.Enforcer("model.conf", "policy.csv")

guard = PermissionGuard(
    user_provider=get_current_user,
    enforcer_provider=get_enforcer,
    error_factory=lambda user, *rv: HTTPException(403, "Forbidden"),
)

app = FastAPI()

@app.get("/me")
@guard.auth_required()
async def me():
    return {"ok": True}

@app.get("/articles")
@guard.require_permission("post", "read")
async def list_articles():
    return []

@app.post("/articles")
@guard.require_permission("post", "write")
async def create_article(data: ArticleIn):
    ...

@app.delete("/articles/{id}")
@guard.require_permission("post", "delete")
async def delete_article(id: int):
    ...
Enter fullscreen mode Exit fullscreen mode

Arguments to require_permission are passed directly to enforcer.enforce(user, *args). The Casbin model decides what "user" and ("post", "read") mean — your route handlers know nothing about it.

Installation

pip install casbin-fastapi-decorator

# Optional extras
pip install "casbin-fastapi-decorator[jwt]"      # JWT token support
pip install "casbin-fastapi-decorator[db]"       # DB-backed policies (SQLAlchemy async)
pip install "casbin-fastapi-decorator[casdoor]"  # Casdoor OAuth2/SSO
Enter fullscreen mode Exit fullscreen mode

Python 3.10+ required.

Deep Dive: JWT Authentication

The [jwt] extra provides JWTUserProvider — a FastAPI dependency that reads and validates a JWT from the Authorization: Bearer header (and optionally a cookie):

from pydantic import BaseModel
from casbin_fastapi_decorator_jwt import JWTUserProvider

class UserSchema(BaseModel):
    role: str  # JWT claim

user_provider = JWTUserProvider(
    secret_key="your-secret-key",
    algorithm="HS256",             # default
    cookie_name="access_token",    # optional: also read from cookie
    user_model=UserSchema,         # optional: validate payload with Pydantic
)
Enter fullscreen mode Exit fullscreen mode

Pass it to PermissionGuard as the user_provider:

import casbin
from fastapi import FastAPI, HTTPException
from casbin_fastapi_decorator import PermissionGuard
from auth import user_provider  # the JWTUserProvider instance above

async def get_enforcer() -> casbin.Enforcer:
    return casbin.Enforcer("model.conf", "policy.csv")

guard = PermissionGuard(
    user_provider=user_provider,
    enforcer_provider=get_enforcer,
    error_factory=lambda *_: HTTPException(403, "Forbidden"),
)

app = FastAPI()

@app.post("/login")
async def login(role: str) -> str:
    import jwt
    return jwt.encode({"role": role}, "your-secret-key", algorithm="HS256")

@app.get("/articles")
@guard.require_permission("post", "read")
async def list_articles():
    return []
Enter fullscreen mode Exit fullscreen mode

When user_model is provided, JWTUserProvider validates the decoded payload via UserSchema.model_validate(). This means your user_provider returns a typed Pydantic model — not a raw dict.

The Casbin model matcher accesses it as an attribute: r.sub.role == p.sub.

Deep Dive: DB-Backed Policies

CSV files work fine for development. Production needs database-backed policies so you can change them without redeploying. Define an ORM model for your policy table:

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

class Base(DeclarativeBase):
    pass

class Policy(Base):
    __tablename__ = "policies"

    id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    sub: Mapped[str]
    obj: Mapped[str]
    act: Mapped[str]

engine = create_async_engine("sqlite+aiosqlite:///./app.db")
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
Enter fullscreen mode Exit fullscreen mode

Wire it up with DatabaseEnforcerProvider:

from casbin_fastapi_decorator import PermissionGuard
from casbin_fastapi_decorator_db import DatabaseEnforcerProvider
from fastapi import HTTPException

enforcer_provider = DatabaseEnforcerProvider(
    model_path="casbin/model.conf",
    session_factory=async_session,
    policy_model=Policy,
    policy_mapper=lambda p: (p.sub, p.obj, p.act),
    default_policies=[("admin", "*", "*")],   # optional static rules on top
)

guard = PermissionGuard(
    user_provider=get_current_user,
    enforcer_provider=enforcer_provider,
    error_factory=lambda *_: HTTPException(403, "Forbidden"),
)
Enter fullscreen mode Exit fullscreen mode

On every request, the provider opens a session, loads all rows, maps them via policy_mapper, merges with default_policies, and returns a fresh casbin.Enforcer. No restart needed — update rows in the DB and the next request sees new policies.

Seed the database on startup with FastAPI's lifespan:

from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    async with async_session() as session:
        session.add_all([
            Policy(sub="admin",  obj="post", act="read"),
            Policy(sub="admin",  obj="post", act="write"),
            Policy(sub="admin",  obj="post", act="delete"),
            Policy(sub="editor", obj="post", act="read"),
            Policy(sub="editor", obj="post", act="write"),
            Policy(sub="viewer", obj="post", act="read"),
        ])
        await session.commit()
    yield
    await engine.dispose()

app = FastAPI(lifespan=lifespan)
Enter fullscreen mode Exit fullscreen mode

Dynamic Arguments: AccessSubject

Sometimes the arguments for enforce() need to come from the request itself — from the database, path params, or request body. That's what AccessSubject is for:

from casbin_fastapi_decorator import AccessSubject

async def get_article(article_id: int) -> dict:
    # Could be a DB query — it's a regular FastAPI dependency
    return {"id": article_id, "owner": "alice"}

@app.get("/articles/{article_id}")
@guard.require_permission(
    AccessSubject(val=get_article, selector=lambda a: a["owner"]),
    "read",
)
async def read_article(article_id: int):
    return {"article_id": article_id}
Enter fullscreen mode Exit fullscreen mode

AccessSubject is resolved through FastAPI DI, then the selector extracts the value before it's passed to enforcer.enforce(user, owner, "read"). The default selector is identity (lambda x: x). This unlocks ownership-based authorization and fine-grained ABAC patterns.

Casdoor Extra: Centralized OAuth2/SSO

Running multiple services? Casdoor is a lightweight open-source IAM that pairs naturally with Casbin. The [casdoor] extra provides a high-level facade:

from fastapi import FastAPI
from casbin_fastapi_decorator_casdoor import CasdoorEnforceTarget, CasdoorIntegration

casdoor = CasdoorIntegration(
    endpoint="http://localhost:8000",
    client_id="your-client-id",
    client_secret="your-client-secret",
    certificate=cert_pem,   # PEM string from Casdoor → Application → Cert
    org_name="my-org",
    application_name="my-app",
    target=CasdoorEnforceTarget(enforce_id="my-org/my-enforcer"),
    redirect_after_login="/",
    cookie_secure=False,    # False for local HTTP dev
    cookie_samesite="lax",
)

app = FastAPI()
app.include_router(casdoor.router)  # GET /login, GET /callback, POST /logout
guard = casdoor.create_guard()

@app.get("/articles")
@guard.require_permission("articles", "read")
async def list_articles():
    return MOCK_DB
Enter fullscreen mode Exit fullscreen mode

CasdoorEnforceTarget decides which Casbin enforcer to call in Casdoor's /api/enforce:

# Static enforcer ID
CasdoorEnforceTarget(enforce_id="my-org/my-enforcer")

# Dynamic — org from user's JWT
CasdoorEnforceTarget(enforce_id=lambda parsed: f"{parsed['owner']}/my-enforcer")

# By permission object
CasdoorEnforceTarget(permission_id="my-org/can_edit_posts")
Enter fullscreen mode Exit fullscreen mode

How the Casbin Model Connects

For reference, here's the model used in the examples above. The matcher uses r.sub.role because the user object is a Pydantic model with a role field:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.obj == p.obj && r.sub.role == p.sub && r.act == p.act
Enter fullscreen mode Exit fullscreen mode

And the policy file:

p, admin,  post, read
p, admin,  post, write
p, admin,  post, delete
p, editor, post, read
p, editor, post, write
p, viewer, post, read
Enter fullscreen mode Exit fullscreen mode

When you switch to user-ID based policies instead of role-based, update the matcher to r.sub.sub == p.sub and adjust your policy rows accordingly — no changes to route handlers.

Comparison

casbin-fastapi-decorator fastapi-authz Manual Depends
API style @guard.require_permission(...) Starlette middleware Depends(func)
Middleware required
Authorization logic Centralized (Casbin model) Centralized (Casbin model) In code
Dynamic enforce args AccessSubject ⚠️ Manual
DB-backed policies [db] extra ✅ Casbin adapters
JWT integration [jwt] extra ❌ built-in
SSO / Casdoor [casdoor] extra
Python 3.10+ 3.6+ any

When to Use It

Good fit:

  • More than a handful of roles and resources
  • Policies need to change without code deployments
  • You want ABAC or ownership-based checks (AccessSubject)
  • Multi-tenancy or SSO across multiple services

Overkill:

  • Two roles (admin/user), no plans to grow
  • Team has zero Casbin experience and no time to ramp up

Wrapping Up

casbin-fastapi-decorator moves authorization out of route handlers and into declarative policies. The @guard.require_permission(resource, action) decorator is explicit, readable, and keeps function signatures clean. Changing who can delete articles means editing a CSV or a database row — not hunting through FastAPI dependencies.

The library is MIT-licensed and actively maintained. Feedback welcome via GitHub issues!


Have you used Casbin with FastAPI before? What approach did you go with? Happy to chat in the comments.

Top comments (0)