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.
- 📦 PyPI: pypi.org/project/casbin-fastapi-decorator
- 🐙 GitHub: github.com/Neko1313/casbin-fastapi-decorator
- 📖 Docs: neko1313.github.io/casbin-fastapi-decorator-docs
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)):
...
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):
...
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
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
)
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 []
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)
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"),
)
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)
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}
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
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")
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
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
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)