golang

Авторизация в Go без боли: как Casbin заменяет километры if-проверок

  • среда, 20 мая 2026 г. в 00:00:14
https://habr.com/ru/companies/first/articles/1036046/

Пока в приложении две роли и три проверки, авторизация умещается в if user.Role == "admin". Но стоит добавить пару ресурсов, ролей и исключений — и условные проверки начинают расползаться по хендлерам, дублироваться и жить своей жизнью. В этой статье разберём, как навести порядок с помощью Casbin: вынесем правила доступа из кода в конфиг, пройдём путь от простого ACL до RBAC с иерархией ролей, соберём HTTP-сервер на Go с авторизационной middleware и обсудим грабли, на которые легко наступить по дороге.

Когда и зачем использовать Casbin

Casbin — библиотека для управления доступом, которая переносит большую часть авторизационной логики из кода в конфиг. Вы описываете, кому и что разрешено, через модели и политики, а Casbin на каждый запрос выносит вердикт: разрешить или запретить. 

Casbin стоит рассмотреть, если:

  • ролей и правил становится достаточно много, чтобы ручные if-проверки превратились в запутанную лапшу;

  • нужна единая точка принятия решений по доступу, а не проверки, разбросанные по хендлерам и сервисам;

  • нужна модель авторизации, которую можно переиспользовать в других сервисах;

  • правила доступа должны меняться без перекомпиляции — например, администратор редактирует права через панель управления, и они применяются на лету.

Обзор возможностей Casbin

Casbin из коробки поддерживает несколько моделей авторизации:

  • ACL (Access Control List) — доступ задается явно для каждой пары “пользователь — ресурс”. Подходит для небольших систем, где нужно точечно раздать права.

  • RBAC (Role-Based Access Control) — пользователи получают роли, а права привязаны к ролям. 

  • ABAC (Attribute-Based Access Control) — доступ зависит от атрибутов: времени суток, IP-адреса, типа устройства и любых других параметров.

Помимо перечисленных моделей, Casbin поддерживает ряд других — их полный список приведен в документации. Библиотека позволяет комбинировать эти модели и политики и адаптировать их для конкретного проекта.

Установка и настройка Casbin в Go

Чтобы установить библиотеку Casbin, выполните:

go get github.com/casbin/casbin/v2

Это основная библиотека. Она уже умеет работать с файловыми политиками, так что для старта ничего больше не нужно.

Основы Casbin

Прежде чем писать код, разберёмся, как Casbin принимает решение «разрешить или запретить». Вся логика сводится к четырём элементам, которые описываются в файле модели.

Когда приложение запрашивает у Casbin «можно ли Алисе читать посты?», происходит следующее:

  1. Запрос (request) — приложение формирует тройку: кто (sub), к чему (obj), что хочет сделать (act).

  2. Сопоставление (matcher) — Casbin перебирает все правила из политики и проверяет, совпадает ли запрос хотя бы с одним из них.

  3. Эффект (effect) — определяет итоговый вердикт: если нашлось совпадение — доступ разрешён, если нет — запрещён.

Весь этот процесс описывается в файле модели, а конкретные правила — в файле политики. Код приложения только задаёт вопрос и получает ответ true или false.

Файл модели

Начнём с самой простой модели: ACL (Access Control List), где мы напрямую указываем, какие действия разрешены конкретному пользователю. Файл config/model.conf:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act

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

Модель состоит из четырёх секций. Разберём каждую.

[request_definition] — формат запроса

Описывает, в каком виде приложение будет формулировать вопрос к Casbin:

  • r — имя запроса (используется в матчере)

  • sub (subject) — кто запрашивает доступ, например "alice"

  • obj (object) — к чему, например "/posts"

  • act (action) — что хочет сделать, например "read"

Когда в коде вы пишете e.Enforce("alice", "/posts", "read"), Casbin превращает это в r.sub = "alice", r.obj = "/posts", r.act = "read". Порядок аргументов соответствует порядку полей в определении.

[policy_definition] — формат правил

Описывает, как устроены строки в файле политики. Буква p — имя политики. Когда в policy.csv написано:

p, alice, /posts, read

Casbin читает это как p.sub = "alice", p.obj = "/posts", p.act = "read". В нашей ACL-модели формат правил совпадает с форматом запроса, но так бывает не всегда — в RBAC-моделях политика может содержать дополнительные поля, например, роль.

[matchers] — логика сопоставления

Матчер — это логическое выражение, которое сравнивает запрос с каждым правилом из политики:

m = r.sub == p.sub && r.obj == p.obj && r.act == p.act

Casbin перебирает все строки политики и проверяет это условие для каждой. Если пришёл запрос Enforce("alice", "/posts", "read"), матчер найдёт строку p, alice, /posts, read и зафиксирует совпадение.

Именно в матчере живёт вся гибкость Casbin. Сюда можно добавить проверку ролей (g(r.sub, p.sub)), регулярные выражения для путей (keyMatch(r.obj, p.obj)) или условия на атрибуты (r.sub.Age >= 18).

[policy_effect] — итоговое решение

Определяет, что делать после того, как матчер отработал по всем правилам:

e = some(where (p.eft == allow))

p.eft (policy effect) — это скрытое поле, которое есть у каждого правила в политике, даже если вы его не объявляли в policy_definition. По умолчанию оно равно allow. То есть когда вы пишете в policy.csv: p, alice, /posts, read

Casbin внутренне читает это как p.sub = "alice", p.obj = "/posts", p.act = "read", p.eft = "allow". Но p.eft можно задать и явно, добавив четвёртое значение:

p, alice, /posts, delete, deny

Теперь у этого правила p.eft = "deny" — оно запрещает, а не разрешает.

where (p.eft == allow) — это условие-фильтр. Из всех правил, которые совпали с запросом по матчеру, where отбирает только те, у которых эффект равен allow. Правила с deny на этом этапе отбрасываются.

some(...) — квантор «существует хотя бы одно». Проверяет, осталось ли после фильтрации хотя бы одно правило. Если да — итоговый результат true (доступ разрешён). Если ни одного — false (доступ запрещен).

Всю конструкцию e = some(where (p.eft == allow)) можно прочитать так: «доступ разрешён, если среди всех сработавших правил есть хотя бы одно с эффектом allow».

Важный нюанс этой модели: если для одного и того же запроса в политике есть и allow, и deny, доступ всё равно будет разрешен — потому что some нашёл хотя бы одно разрешающее правило. Запрещающее правило просто игнорируется.

Также Casbin поддерживает несколько встроенных комбинаций. Например, !some(where (p.eft == deny)) — логика инвертирована. Здесь where фильтрует правила с эффектом deny, some проверяет, есть ли среди них хотя бы одно, а ! (отрицание) переворачивает результат. Читается так: «доступ разрешён, если нет ни одного правила с эффектом deny». Эта модель подходит, когда нужно совмещать разрешающие и запрещающие правила, причем запрет имеет приоритет. Например:

p, alice, /posts, read, allow
p, alice, /posts, read, deny

С эффектом some(where (p.eft == allow)) — доступ разрешён (нашлось allow-правило). С эффектом !some(where (p.eft == deny)) — доступ запрещен (нашлось deny-правило).

Для простой ACL-модели, где все правила только разрешающие, достаточно первого варианта — some(where (p.eft == allow)).

Файл политики

Файл policy.csv — это таблица разрешений. Для нашей ACL-модели:

p, alice, /posts, read
p, alice, /posts, write
p, bob, /posts, read

Здесь alice может читать и писать посты, а bob — только читать. Префикс p указывает, что строка относится к определению политики (тому самому p из policy_definition).

Простой пример: политика ACL

ACL — самая прямолинейная модель: для каждого пользователя явно указано, что ему можно. 

Модель (config/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.sub == p.sub && r.obj == p.obj && r.act == p.act

Политика (config/policy.csv):

p, alice, /posts, read
p, alice, /posts, write
p, bob, /posts, read

Alice может читать и писать в /posts. Bob — только читать. Всё остальное запрещено по умолчанию.

Код:

package main

import (
    "fmt"
    "log"

    "github.com/casbin/casbin/v2"
)

func main() {
    e, err := casbin.NewEnforcer("config/model.conf", "config/policy.csv")
    if err != nil {
        log.Fatalf("ошибка при загрузке конфига: %v", err)
    }

    simpleCheck(e, "alice", "/posts", "read")
    simpleCheck(e, "alice", "/posts", "write")
    simpleCheck(e, "bob", "/posts", "read")
    simpleCheck(e, "bob", "/posts", "write")
}

func simpleCheck(e *casbin.Enforcer, sub, obj, act string) {
    ok, err := e.Enforce(sub, obj, act)
    if err != nil {
        log.Printf("внутренняя ошибка: %v", err)
        return
    }

    if ok {
        fmt.Printf("%s разрешено %s %s\n", sub, act, obj)
    } else {
        fmt.Printf("%s запрещено %s %s\n", sub, act, obj)
    }
}

NewEnforcer принимает два файла — модель и политику — и создаёт объект, который умеет отвечать на один вопрос: «можно или нельзя?». Вызов e.Enforce("alice", "/posts", "write") берёт эти три аргумента (субъект, объект, действие), прогоняет их через матчер из модели, ищет совпадение среди правил политики и возвращает true или false. Если совпавшее правило нашлось — доступ разрешён, если нет — запрещён. Весь Casbin сводится к этому: загрузил правила, вызвал Enforce, получил булево значение. А всё остальное — роли, адаптеры, сложные матчеры — это способы усложнить правила, не усложняя точку проверки.

Запускаем:

go run main.go

Получаем такой вывод:

alice разрешено read /posts
alice разрешено write /posts
bob разрешено read /posts
bob запрещено write /posts

Мы собрали минимальный рабочий пример: настроили модель, описали права в policy.csv, прогнали через Enforce четыре запроса и получили ожидаемый вывод. Этого уже достаточно, чтобы прикрутить Casbin к небольшому сервису с фиксированным набором пользователей. Дальше будем смотреть, как та же связка model + policy + Enforce масштабируется на более сложные модели.

Реализация системы авторизации на основе RBAC

ACL прозрачен и удобен, пока пользователей немного. Но с ростом системы правила начинают копироваться, а любое изменение прав превращается в массовую правку policy.csv. Понятий "группа" или "роль" в модели нет. Решает эту проблему модель RBAC, которая добавляет между пользователем и правами промежуточный слой ролей.

Разберем модель на более реальном примере: соберём простой HTTP-сервер и вынесем проверку доступа в middleware. Начнём с модели — файл config/model.conf — это RBAC-модель, которую мы разбирали в предыдущем разделе:

[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act

Функция g(r.sub, p.sub) в матчере означает, что Casbin будет проверять не имя пользователя напрямую, а его принадлежность к роли. Это позволяет назначать права ролям, а пользователям — роли.

Теперь политика. Файл config/policy.csv описывает, какие роли имеют доступ к каким ресурсам:

p, admin, /admin, read
p, admin, /admin, write
p, admin, /dashboard, read

p, editor, /dashboard, read
p, editor, /dashboard, write

p, viewer, /dashboard, read

g, alice, admin
g, bob, editor
g, charlie, viewer

Здесь видна иерархия доступа. admin может всё: читать и писать в /admin и читать /dashboard. editor может читать и писать в /dashboard, но не имеет доступа к /admin. viewer может только читать /dashboard. В конце — назначение ролей: alice — администратор, bob — редактор, charlie — читатель. Ниже представлен рисунок, как выглядит схема в редакторе Casbin:

Middleware для авторизации

В реальном приложении проверка доступа не вызывается вручную в каждом обработчике — для этого используется middleware. Создадим файл middleware.go:

package main

import (
	"github.com/casbin/casbin/v2"
	"net/http"
)

func AuthorizationMiddleware(enforcer *casbin.Enforcer) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// В реальном проекте имя пользователя берётся из токена/сессии.
			// Здесь для простоты — из заголовка.
			user := r.Header.Get("X-User")
			if user == "" {
				http.Error(w, "пользователь не указан", http.StatusUnauthorized)
				return
			}
			act := methodToAction(r.Method)

			ok, err := enforcer.Enforce(user, r.URL.Path, act)
			if err != nil {
				http.Error(w, "ошибка проверки доступа", http.StatusInternalServerError)
				return
			}

			if !ok {
				http.Error(w, "доступ запрещён", http.StatusForbidden)
				return
			}

			next.ServeHTTP(w, r)
		})
	}
}

func methodToAction(method string) string {
	switch method {
	case http.MethodGet:
		return "read"
	case http.MethodPost, http.MethodPut, http.MethodPatch:
		return "write"
	case http.MethodDelete:
		return "delete"
	default:
		return "read"
	}
}

Разберём, что здесь происходит. AuthorizationMiddleware принимает Enforcer и возвращает функцию, которую можно навесить на любой http.Handler. Для каждого входящего запроса middleware извлекает имя пользователя из заголовка X-User, преобразует HTTP-метод в действие (GET → read, POST/PUT/PATCH → write, DELETE → delete) и передает (user, path, action) в Enforce().

Если Enforce() вернул true — запрос проходит дальше к обработчику. Если false — клиент получает 403 Forbidden. Если произошла внутренняя ошибка — 500. Обратите внимание: отсутствие заголовка X-User обрабатывается отдельно как 401 Unauthorized — это аутентификация, не авторизация.

В продакшене вместо заголовка X-User вы будете извлекать пользователя из JWT-токена или сессии, но сама структура middleware останется такой же.

Сборка: HTTP-сервер с авторизацией

main.go — точка входа, где всё соединяется:

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/casbin/casbin/v2"
)

func main() {
	enforcer, err := casbin.NewEnforcer("config/model.conf", "config/policy.csv")
	if err != nil {
		log.Fatalf("не удалось создать enforcer: %v", err)
	}

	mux := http.NewServeMux()

	mux.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
		user := r.Header.Get("X-User")
		fmt.Fprintf(w, "Добро пожаловать на дашборд, %s!", user)
	})

	mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
		user := r.Header.Get("X-User")
		fmt.Fprintf(w, "Админ-панель. Привет, %s!", user)
	})

	// Оборачиваем весь mux в middleware
	handler := AuthorizationMiddleware(enforcer)(mux)

	log.Println("Сервер запущен на :8080")
	log.Fatal(http.ListenAndServe(":8080", handler))
}

Код компактный, и авторизация полностью отделена от бизнес-логики. Обработчики /dashboard и /admin ничего не знают о правах доступа — за это отвечает middleware. Enforcer создается один раз при старте и переиспользуется для всех запросов.

Ключевая строка — AuthorizationMiddleware(enforcer)(mux). Она оборачивает весь маршрутизатор, так что каждый запрос к любому маршруту проходит через проверку Casbin. Если нужно защитить только определенные маршруты, можно оборачивать отдельные обработчики вместо всего mux.

Проверяем в действии

Запускаем сервер и тестируем с помощью curl:

go run main.go middleware.go

Затем в другом терминале:

# alice (admin) — доступ к /dashboard 
curl -H "X-User: alice" http://localhost:8080/dashboard
# Добро пожаловать на дашборд, alice!

# alice (admin) — доступ к /admin 
curl -H "X-User: alice" http://localhost:8080/admin
# Админ-панель. Привет, alice!

# bob (editor) — доступ к /dashboard 
curl -H "X-User: bob" http://localhost:8080/dashboard
# Добро пожаловать на дашборд, bob!

# bob (editor) — доступ к /admin 
curl -H "X-User: bob" http://localhost:8080/admin
# доступ запрещён

# charlie (viewer) — чтение /dashboard 
curl -H "X-User: charlie" http://localhost:8080/dashboard
# Добро пожаловать на дашборд, charlie!

# charlie (viewer) — запись в /dashboard 
curl -X POST -H "X-User: charlie" http://localhost:8080/dashboard
# доступ запрещён

# Неизвестный пользователь — 
curl -H "X-User: dave" http://localhost:8080/dashboard
# доступ запрещён

# Без заголовка X-User — 401
curl http://localhost:8080/dashboard
# пользователь не указан

В Go-коде нет ни одного if user == "alice" или if role == "admin". Вся логика авторизации живёт в model.conf и policy.csv. Чтобы дать charlie права на запись в /dashboard, достаточно добавить строку p, viewer, /dashboard, write в policy.csv и перезапустить сервер — код не изменится.

Проблемы, с которыми можно столкнуться

Casbin — хороший инструмент, но у него есть особенность, на которую легко наткнуться при отладке. Сами вызовы вроде NewEnforcer, Enforce или LoadPolicy возвращают error и о настоящих сбоях честно сообщают. Но когда Enforce отрабатывает без ошибки и возвращает false, понять, почему именно отказ — какое правило не сматчилось, какая роль не подтянулась — по умолчанию сложно: библиотека просто говорит «нельзя». В этом разделе разберём типичные ситуации, из-за которых Enforce возвращает неожиданный false, и инструменты, которые помогают быстро локализовать причину (EnableLog, EnforceEx, кастомный логгер).

Опечатки в policy.csv

Casbin не валидирует содержимое политик. Если вы напишете aice вместо alice, ошибки не будет — просто ни одно правило не сработает для настоящей alice, и Enforce молча вернёт false. Найти такую опечатку помогает EnforceEx — он возвращает не только вердикт, но и сработавшее правило (или его отсутствие). А когда опечатка найдена и исправлена в файле, перезапускать сервис не нужно — e.LoadPolicy() перечитает политику из адаптера в рантайме.

#  Опечатка — Casbin не предупредит
p, aice, /posts, read
#  Правильно
p, alice, /posts, read

То же самое с действиями и ресурсами. Написали raed вместо read? Enforce("alice", "/posts", "read") вернёт false, потому что в политике нет строки с действием read для alice — есть только raed. Никакого сообщения об ошибке вы не получите.

Проблемы с пробелами

Ещё одна частая ошибка — лишние пробелы в policy.csv. Casbin обрезает пробелы до значения (между запятой и началом слова) — неважно, один там пробел, два или таб. А вот пробелы после значения (между концом слова и следующей запятой) сохраняются как часть этого значения.

p, alice, /posts, read
#  ↑ этот пробел Casbin отбросит — будет "alice"

p, alice ,/posts, read
#       ↑ а этот сохранится — будет "alice "

Во втором случае subject сохранится как "alice " (с пробелом в конце), а Enforce("alice", ...) передаёт "alice" без пробела. Строки не совпадут, доступ будет запрещён — причём без какой-либо ошибки в логах. Особенно легко на это нарваться, когда политики редактируются в текстовом редакторе с автоформатированием или копируются из таблицы. Если сомневаетесь в этом, то можете проверить, запустив тут небольшой тест.

Три «безопасных» кейса возвращают true, а кейс с пробелом перед запятой — false, потому что subject сохраняется как "alice" с trailing-пробелом и не матчится с "alice".

Порядок аргументов в Enforce()

Аргументы Enforce() должны точно соответствовать порядку полей в request_definition. Если в модели написано r = sub, obj, act, то вызов должен быть Enforce(sub, obj, act). Перепутали местами — получите отказ в доступе без какой-либо ошибки:

// Модель: r = sub, obj, act

//  Перепутан порядок — obj и act местами
ok, _ := e.Enforce("alice", "read", "/posts")
// Casbin интерпретирует: sub="alice", obj="read", act="/posts"
// В политике нет строки (alice, read, /posts) — результат false

//  Правильный порядок
ok, _ := e.Enforce("alice", "/posts", "read")

Компилятор Go здесь не поможет: все аргументы — строки, типы совпадают. Ошибку можно поймать только тестами или внимательным чтением модели.

Ошибки в матчере

Синтаксис матчера не проверяется при создании Enforcer — ошибка всплывет только при вызове Enforce(). Причём это будет ошибка в возвращаемом err, а не паника, так что если вы игнорируете ошибку — поведение станет непредсказуемым:

#  Опечатка в матчере — "p.sbu" вместо "p.sub"
[matchers]
m = r.sub == p.sbu && r.obj == p.obj && r.act == p.act
ok, err := e.Enforce("alice", "/posts", "read")
if err != nil {
    // Здесь будет ошибка вроде: "p.sbu is undefined"
    // Если err не проверять, ok будет false — и вы решите,
    // что правила просто нет, хотя проблема в модели
    log.Printf("ошибка enforce: %v", err)
}

Поэтому всегда проверяйте err от Enforce(). Ошибка матчера — это не проблемы с правами, а, скорее всего, сломанная модель. Отвечать на такие ошибки нужно кодом 500, а не 403.

Советы и лучшие практики

В этой главе — несколько практических советов, которые помогают при работе с Casbin в реальных проектах: как отлаживать решения Enforce, где хранить политики и как покрывать их тестами.

EnforceEx для отладки

Как мы видели в разделе про отладку, понять, почему Enforce вернул false (или внезапно true), бывает непросто: метод возвращает только булево значение, без контекста. EnforceEx решает эту проблему — он дополнительно показывает, какое именно правило сработало:

ok, reason, err := e.EnforceEx("alice", "/posts", "read")
if err != nil {
    log.Printf("ошибка: %v", err)
    return
}

if ok {
    fmt.Printf("доступ разрешён, сработавшее правило: %v\n", reason)
    // Вывод: доступ разрешён, сработавшее правило: [alice /posts read]
} else {
    fmt.Println("доступ запрещён — ни одно правило не совпало")
}

reason — слайс строк с полями сработавшего правила. Если доступ запрещен, слайс будет пустым — это сразу подсказывает, что проблема не в логике матчера, а в отсутствии подходящего правила (опечатка в policy.csv, не подтянулась роль через g, неправильный путь и т. п.).

В сложных случаях, когда нужно понять не только какое правило сработало, но и как выполнялся матчер, помогает EnableLog(true) — Casbin начнёт логировать каждый вызов Enforce с деталями: какие правила проверялись, какие роли подтянулись, какой получился итоговый эффект. 

Храните политики в базе данных, а не в файлах

CSV-файлы хороши, пока сервис деплоится в одном экземпляре и политики правит сам разработчик. Как только появляется вторая реплика, всплывают три проблемы: каждая реплика работает со своим файлом, параллельные записи приводят к гонкам, а UI поверх CSV не построить. Все три закрываются переходом на адаптер для внешнего хранилища. Для PostgreSQL это выглядит так:

import (
    "github.com/casbin/casbin/v2"
    gormadapter "github.com/casbin/gorm-adapter/v3"
)

// Адаптер подключается к базе и автоматически создаёт таблицу casbin_rule
a, err := gormadapter.NewAdapter("postgres",
    "host=localhost user=app password=secret dbname=myapp port=5432")
if err != nil {
    log.Fatal(err)
}

// Передаём адаптер вместо пути к CSV-файлу
e, err := casbin.NewEnforcer("config/model.conf", a)

После этого AddPolicy, RemovePolicy и SavePolicy работают напрямую с базой. Адаптеры есть для MySQL, MongoDB, Redis и т. д., полный список есть в документации Casbin.

Важный нюанс: сам по себе адаптер не синхронизирует политики между инстансами. Если один инстанс вызвал AddPolicy и записал правило в БД, остальные про него не узнают, пока не вызовут LoadPolicy() сами. Для двух-трёх реплик можно перечитывать политики по таймеру или после каждого изменения через админку. На большем количестве инстансов уже стоит подключить Watcher — компонент, который слушает изменения через Redis/Kafka/etcd и автоматически вызывает LoadPolicy на всех инстансах.

Пишите тесты для политик

Политики — это бизнес-логика, записанная в другом формате. И раз она определяет, кто что может делать в системе, её нужно тестировать так же, как любой другой код. Табличные тесты Go подходят для этого идеально:

func TestACLPolicy(t *testing.T) {
    e, err := casbin.NewEnforcer("config/model.conf", "config/policy.csv")
    if err != nil {
        t.Fatalf("не удалось создать enforcer: %v", err)
    }

    tests := []struct {
        name          string
        sub, obj, act string
        want          bool
    }{
        {"alice читает posts",       "alice",   "/posts", "read",  true},
        {"alice пишет в posts",      "alice",   "/posts", "write", true},
        {"bob читает posts",         "bob",     "/posts", "read",  true},
        {"bob НЕ пишет в posts",     "bob",     "/posts", "write", false},
        {"unknown user → default deny", "ghost", "/posts", "read",  false},
        {"unknown resource → deny",  "alice",   "/admin", "read",  false},
        {"unknown action → deny",    "alice",   "/posts", "delete", false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := e.Enforce(tt.sub, tt.obj, tt.act)
            if err != nil {
                t.Fatalf("Enforce: %v", err)
            }
            if got != tt.want {
                t.Errorf("Enforce(%q, %q, %q) = %v, ожидалось %v",
                    tt.sub, tt.obj, tt.act, got, tt.want)
            }
        })
    }
}

Такие тесты стоит запускать в CI и дописывать кейс при каждом изменении политики. Casbin не валидирует ни модель, ни правила — опечатка в policy.csv загрузится молча, и без тестов вы узнаете о ней по жалобам пользователей.

Заключение

В начале статьи мы говорили про разрастающиеся if user.Role == "admin" по хендлерам и сервисам. Теперь у вас есть инструмент, который эту проблему закрывает: правила лежат в одном файле (или БД), бизнес-код их не видит, а единственная точка проверки — Enforce — даёт булево «можно или нельзя».

Поэкспериментировать с моделями удобно в онлайн-редакторе Casbin — он показывает результат матчинга прямо в браузере. Подробный справочник по моделям, адаптерам и функциям — в официальной документации. Код из статьи — на GitHub.

Автор alex_name_m


НЛО прилетело и оставило здесь промокод для читателей нашего блога:

-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS