Когда в FastAPI-проекте появляется нормальная авторизация, код быстро начинает расползаться в стороны.

Сначала все выглядит терпимо: один Depends(get_current_user), один Depends(get_enforcer), одна ручная проверка. Потом роутов становится больше, правил доступа становится больше, и внезапно половина endpoint’ов начинает содержать не бизнес-логику, а обвязку вокруг нее.

В какой-то момент меня перестал устраивать и классический подход через dependency injection в каждом роуте, и вариант с middleware. Хотелось, чтобы правило доступа было видно прямо рядом с маршрутом, но при этом не приходилось таскать авторизацию в сигнатуры всех функций.

В итоге я собрал casbin-fastapi-decorator — тонкий слой над Casbin для FastAPI, который позволяет описывать authorization через декораторы.

Идея простая:

@app.get("/articles")
@guard.require_permission("post", "read")
async def list_articles():
    return [{"id": 1, "title": "Hello"}]

То есть правило доступа лежит рядом с роутом, читается сразу, но не засоряет endpoint техническими зависимостями.

В чем проблема обычного подхода

Если интегрировать Casbin в FastAPI в лоб, очень часто получается что-то вроде этого:

from fastapi import Depends, HTTPException

@app.get("/articles")
async def list_articles(
    user: User = Depends(get_current_user),
    enforcer = Depends(get_enforcer),
):
    if not enforcer.enforce(user, "post", "read"):
        raise HTTPException(403, "Forbidden")
    return [{"id": 1, "title": "Hello"}]

Код рабочий. Но у него быстро вылезают проблемы.

Во-первых, authorization-логика начинает повторяться.
Во-вторых, сигнатуры роутов забиваются зависимостями, которые не имеют отношения к самой бизнес-операции.
В-третьих, при чтении кода не сразу видно, какой именно permission нужен конкретному endpoint’у.
В-четвертых, любое усложнение — например, динамические аргументы для enforce(...) — начинает размазываться по проекту.

Альтернатива в виде middleware тоже не идеальна. Middleware хорошо подходит для глобальной обработки запросов, но хуже работает там, где права нужно описывать точечно, на уровне конкретного маршрута.

Мне хотелось получить такой API, где правило доступа читается прямо в месте объявления маршрута.

Что я хотел получить

Хотелось свести описание доступа к чему-то такому:

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

А для проверки просто залогинен ли пользователь — к такому:

@app.get("/me")
@guard.auth_required()
async def me():
    ...

То есть требования были довольно приземленные:

  • правило доступа должно быть видно рядом с роутом

  • endpoint signature не должна захламляться

  • Casbin должен оставаться Casbin, без выдумывания новой модели доступа

  • auth layer должен легко собираться из заменяемых частей

Что в итоге получилось

Так появился casbin-fastapi-decorator.

По сути, библиотека строится вокруг одной центральной сущности — PermissionGuard. Это фабрика декораторов авторизации. Ей нужно передать три вещи:

  • user_provider — как получить текущего пользователя

  • enforcer_provider — как получить Casbin enforcer

  • error_factory — какое исключение выбрасывать при отказе

Базовый пример выглядит так:

from fastapi import FastAPI, HTTPException
from casbin_fastapi_decorator import PermissionGuard

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

app = FastAPI()


@app.get("/articles")
@guard.require_permission("post", "read")
async def list_articles():
    return [{"id": 1, "title": "Hello"}]

Снаружи это кажется мелочью, но на практике именно эта мелочь сильно меняет читаемость кода. Правило доступа больше не спрятано в теле функции и не уехало в middleware-конфигурацию. Оно лежит рядом с маршрутом.

Важный момент: библиотека не подменяет Casbin

Для меня это был принципиальный момент.

Я не хотел делать “еще одну систему прав поверх Casbin”. Библиотека не навязывает ни RBAC, ни ABAC, ни какую-то собственную схему. Она просто помогает удобнее встроить стандартный enforce(...) в FastAPI.

Если у вас RBAC-модель, значит работает RBAC.
Если у вас ABAC-модель, значит работает ABAC.
Если у вас своя более хитрая Casbin-конфигурация, библиотека не должна этому мешать.

Например, простая RBAC-модель может выглядеть так:

[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

А политики так:

p, viewer, post, read
p, editor, post, read
p, editor, post, write
p, admin, post, read
p, admin, post, write
p, admin, post, delete

Библиотека в этом сценарии делает только одно: аккуратно доводит user и аргументы из декоратора до enforcer.enforce(...).

Быстрый старт

Ниже минимальный пример приложения на FastAPI, где роль пользователя передается через Bearer token, а политики лежат в файлах Casbin.

casbin/model.conf

[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

casbin/policy.csv

p, viewer, post, read
p, editor, post, read
p, editor, post, write
p, admin, post, read
p, admin, post, write
p, admin, post, delete

main.py

from contextlib import asynccontextmanager
from typing import Annotated

from fastapi import FastAPI, HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel

from casbin_fastapi_decorator import PermissionGuard
from casbin_fastapi_decorator_file import CachedFileEnforcerProvider

bearer = HTTPBearer(auto_error=False)


class User(BaseModel):
    role: str


async def get_current_user(
    credentials: Annotated[
        HTTPAuthorizationCredentials | None,
        Security(bearer),
    ],
) -> User:
    if not credentials:
        raise HTTPException(401, "Unauthorized")
    return User(role=credentials.credentials)


enforcer_provider = CachedFileEnforcerProvider(
    model_path="casbin/model.conf",
    policy_path="casbin/policy.csv",
)


@asynccontextmanager
async def lifespan(_app: FastAPI):
    async with enforcer_provider:
        yield


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

app = FastAPI(lifespan=lifespan)


@app.get("/posts")
@guard.require_permission("post", "read")
async def list_posts():
    return [{"id": 1, "title": "Hello World"}]


@app.post("/posts")
@guard.require_permission("post", "write")
async def create_post():
    return {"id": 2, "title": "New Post"}


@app.delete("/posts/{post_id}")
@guard.require_permission("post", "delete")
async def delete_post(post_id: int):
    return {"id": post_id, "deleted": True}

Проверка руками:

curl -H "Authorization: Bearer viewer" http://localhost:8000/posts
curl -X POST -H "Authorization: Bearer viewer" http://localhost:8000/posts
curl -X POST -H "Authorization: Bearer editor" http://localhost:8000/posts

В результате viewer сможет читать, но не писать, а editor сможет и читать, и писать.

Почему мне не подошел middleware

На словах middleware кажется хорошим местом для авторизации: один раз повесили, и все запросы проходят через единый слой проверки.

На практике у этого подхода есть цена.

Когда логика доступа живет в middleware, ее часто приходится связывать с HTTP method, path pattern и дополнительными таблицами соответствия. Как только правила становятся чуть менее тривиальными, код начинает отрываться от самих маршрутов.

Получается странная ситуация: чтобы понять, почему POST /articles требует одно право, а DELETE /articles/{id} — другое, нужно ходить по нескольким слоям кода.

С декораторами правило лежит там, где его ожидаешь увидеть:

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

Именно эта локальность для меня оказалась главным аргументом.

Почему просто Depends(...) тоже не идеален

FastAPI dependency injection — сильный инструмент, и я совершенно не хотел от него отказываться. Но есть важный нюанс: dependency injection хорош как механизм разрешения зависимостей, а не как основной DSL для описания authorization.

Когда роут выглядит так:

@app.get("/articles")
async def list_articles(
    user: User = Depends(get_current_user),
    enforcer = Depends(get_enforcer),
):
    ...

сигнатура начинает описывать не только входные данные endpoint’а, но и внутреннюю техническую кухню.

Мне хотелось, чтобы FastAPI по-прежнему разрешал зависимости, но внешний API для роутов был чище. По сути, casbin-fastapi-decorator не заменяет DI, а использует его внутри себя.

То есть dependency injection остается, просто перестает торчать из каждого обработчика наружу.

Динамические аргументы через AccessSubject

Статические проверки — это хорошо, но реальная авторизация редко ограничивается строками "post" и "read".

Часто нужно учитывать данные из path parameters, сущности из базы, владельца ресурса, отдел пользователя, visibility объекта и так далее.

Для этого в библиотеке есть AccessSubject.

Пример:

from casbin_fastapi_decorator import AccessSubject

async def get_post_owner(post_id: int) -> str:
    post = await db.get_post(post_id)
    return post.owner_id


@app.delete("/posts/{post_id}")
@guard.require_permission(
    "post",
    AccessSubject(val=get_post_owner),
)
async def delete_post(post_id: int):
    ...

Здесь значение для enforce(...) будет вычислено во время запроса. FastAPI сам разрулит dependency, передаст post_id, а библиотека подставит результат в Casbin.

Есть и второй полезный сценарий — когда dependency возвращает не примитив, а объект, из которого нужно выбрать конкретное поле:

from casbin_fastapi_decorator import AccessSubject

async def get_post(post_id: int) -> Post:
    return await db.get_post(post_id)


@app.put("/posts/{post_id}")
@guard.require_permission(
    "post",
    AccessSubject(
        val=get_post,
        selector=lambda post: post.owner_id,
    ),
)
async def update_post(post_id: int):
    ...

Это дало хороший компромисс между декларативностью и гибкостью.

Почему core должен быть маленьким

Когда я начал раскладывать реальные сценарии использования, стало очевидно, что core-пакет не должен тащить на себе все подряд.

Одним нужен файл с политиками.
Другим нужен JWT.
Третьим нужны политики из базы.
Четвертым нужен внешний identity provider.

Если все это засунуть в один пакет, библиотека быстро превращается в тяжеловесный комбайн.

Поэтому я разделил решение на core и extras.

Extra file

Самый очевидный сценарий — model.conf и policy.csv на диске.

Наивная реализация выглядит так: на каждый запрос создается новый casbin.Enforcer(...). Для небольшого примера это терпимо, но в реальном сервисе лишняя работа на каждый request не радует.

Поэтому появился CachedFileEnforcerProvider.

Он:

  • загружает enforcer один раз

  • держит его в памяти

  • следит за изменениями model.conf и policy.csv

  • подхватывает изменения без рестарта приложения

Для локальной разработки и небольших сервисов это особенно приятно: можно менять policy на лету и сразу видеть результат.

Extra jwt

Еще одна повторяющаяся история — разбор JWT.

Почти в каждом проекте в каком-то виде пишется один и тот же код:

  • достать токен из Bearer header

  • иногда уметь доставать из cookie

  • декодировать

  • провалидировать

  • превратить payload в модель пользователя

Чтобы не заставлять пользователя каждый раз писать этот код руками, появился JWTUserProvider.

В результате guard можно собрать уже не на кастомной функции, а на готовом user provider.

Extra db

Файлы — это удобно, пока политика меняется редко и живет рядом с приложением.

Но как только права начинают управляться динамически, например через админку, policy.csv становится неудобным.

Для этого появился DatabaseEnforcerProvider, который:

  • читает policy rows из SQLAlchemy

  • кеширует enforcer

  • следит за изменениями модели и данных

  • обновляет состояние без пересоздания enforcer на каждый запрос

Это уже более production-oriented сценарий: политики хранятся в базе, а приложение продолжает работать с кешированным enforcer.

Extra casdoor

Отдельный класс задач — интеграция с внешним identity/access решением.

Если в проекте уже есть Casdoor, хочется не только валидировать токены, но и встроить логин, callback, cookie-based auth и remote authorization в единую схему.

Для этого появился extra с Casdoor:

  • login/logout routes

  • OAuth2 callback

  • cookie-based authentication

  • remote Casbin enforcement через Casdoor API

То есть библиотека может работать не только с локальными политиками, но и как удобная интеграционная прослойка для внешнего access stack.

Архитектурная мысль, к которой я пришел

Во время разработки у меня сформировался довольно простой принцип:

authentication, enforcer lifecycle и declaration of permissions не должны быть слеплены в одну сущность.

Когда эти части разделены, система становится заметно гибче.

В текущем виде библиотека собирается из трех независимых слоев:

  1. user_provider

  2. enforcer_provider

  3. PermissionGuard

Это дает несколько важных эффектов.

Во-первых, можно заменить способ получения пользователя, не переписывая все роуты.
Во-вторых, можно заменить источник политик, не меняя внешний API авторизации.
В-третьих, можно делать разные guard’ы для разных контекстов.
В-четвертых, core остается маленьким и понятным.

На мой взгляд, именно эта декомпозиция сделала библиотеку жизнеспособной.

Где такой подход полезен

Лучше всего декораторный подход показал себя там, где:

  • много CRUD endpoint’ов

  • права должны быть явно видны в коде

  • используется Casbin

  • есть желание не захламлять сигнатуры роутов

  • authorization rules должны быть локальными, а не размазанными по middleware

То есть это хороший вариант для:

  • внутренних сервисов

  • backoffice API

  • admin-панелей

  • B2B API

  • сервисов с большим количеством route-level permission checks

Где такой подход не нужен

Если у вас почти нет authorization, а вся защита сводится к “пользователь должен быть просто залогинен”, возможно, библиотека вам и не нужна.

Если весь access control централизован вне приложения, а FastAPI-сервис только доверяет внешнему gateway, тоже нет смысла городить лишний слой.

Мне кажется, такой инструмент полезен именно там, где permission checks реально живут в коде приложения и их много.

Что мне хотелось улучшить в developer experience

Если попытаться сформулировать коротко, то мне хотелось добиться нескольких вещей одновременно:

  • чтобы доступ читался рядом с роутом

  • чтобы FastAPI DI не исчезал, но уходил внутрь

  • чтобы Casbin оставался стандартным Casbin

  • чтобы simple cases оставались простыми

  • чтобы сложные cases не выглядели как хак

Собственно, из этого и вырос текущий API.

Что дальше

Сейчас мне наиболее интересны такие направления:

  • улучшение DX для более сложных ABAC-сценариев

  • расширение примеров под production use cases

  • развитие интеграций

  • улучшение провайдеров enforcer с точки зрения кеширования и hot reload

Отдельно интересно, насколько такой стиль вообще близок сообществу FastAPI. Потому что по моим ощущениям авторизация в Python backend’ах часто либо слишком размазана, либо слишком глубоко упрятана в инфраструктурный слой.

Мне же хотелось оставить ее явной.

Итог

Я не пытался заменить Casbin.
Я не пытался отменить FastAPI dependencies.
Я не пытался построить универсальный auth framework на все случаи жизни.

Мне хотелось сделать маленький слой, который позволяет писать authorization так, чтобы:

  • правило доступа было видно сразу

  • сигнатуры роутов оставались чистыми

  • Casbin продолжал отвечать за модель и policy

  • инфраструктурные детали не лезли в каждый endpoint

В итоге формула получилась простой:

  • FastAPI отвечает за dependency resolution

  • Casbin отвечает за принятие решения

  • PermissionGuard отвечает за удобный route-level API

Для меня это оказалось самым удобным способом встроить authorization в FastAPI без middleware-магии и без повторяющегося бойлерплейта в каждом роуте.

Если вы тоже используете Casbin или строили authorization в FastAPI по-другому, будет интересно сравнить подходы.

Где посмотреть проект

Если захотите попробовать библиотеку или посмотреть примеры:

Буду рад обратной связи по API, DX, интеграциям и реальным сценариям использования.