Когда в 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 enforcererror_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
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 не должны быть слеплены в одну сущность.
Когда эти части разделены, система становится заметно гибче.
В текущем виде библиотека собирается из трех независимых слоев:
user_providerenforcer_providerPermissionGuard
Это дает несколько важных эффектов.
Во-первых, можно заменить способ получения пользователя, не переписывая все роуты.
Во-вторых, можно заменить источник политик, не меняя внешний 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 по-другому, будет интересно сравнить подходы.
Где посмотреть проект
Если захотите попробовать библиотеку или посмотреть примеры:
GitHub:
https://github.com/Neko1313/casbin-fastapi-decoratorДокументация:
https://neko1313.github.io/casbin-fastapi-decorator-docs/Casbin Ecosystem:
https://casbin.org/ecosystem/
Буду рад обратной связи по API, DX, интеграциям и реальным сценариям использования.
