Keycloak проверил пароль и выдал пользователю токен. Но дальше встаёт неприятный вопрос: а что этому пользователю вообще можно? Сам по себе токен не запрещает ничего — он лишь говорит, кто пришёл и с какими ролями. Превратить «у тебя есть роль» в «тебе сюда нельзя» — задача вашего сервиса, и Keycloak за вас её не сделает.
В Go у этой задачи нет готового каркаса вроде Spring Security: нет аннотаций, нет конвертера ролей из коробки, нет hasRole. Есть net/http, контекст запроса и ваш код. Это проще, чем кажется, и честнее: каждая проверка видна в маршрутах и в обработчике.
Аутентификация и авторизация — это разные вещи
- Аутентификация — «кто ты?». Keycloak узнал пользователя и выдал токен; сервис проверил подпись и понял, что токен настоящий.
- Авторизация — «что тебе можно?». Создать заказ, посмотреть чужой профиль, зайти в админку.
Keycloak отвечает за первую часть и кладёт в токен роли. Вторую — «с такими ролями сюда можно, а сюда нельзя» — решает сервис. Дальше вся статья про неё. Проверка подписи по JWKS, iss и aud разобрана в статье про проверку токенов в Go; здесь считаем, что токен уже проверен.
Где роли лежат внутри токена
Keycloak различает realm-роли (видны всем приложениям realm: customer, admin) и client-роли (привязаны к одному приложению: order-manager в сервисе заказов). Лежат они в разных местах токена, и это ключевой факт, на котором спотыкаются чаще всего:
{
"sub": "a1b2c3d4-...",
"preferred_username": "ivan",
"realm_access": {
"roles": ["customer", "premium"]
},
"resource_access": {
"orders-service": {
"roles": ["order-manager"]
}
},
"scope": "openid profile email"
}
realm_access.roles — плоский список realm-ролей. resource_access.<client>.roles — client-роли, сгруппированные по идентификатору client, тому самому clientId из настроек Keycloak, а не по отображаемому имени. scope — не про роли пользователя, а про то, что разрешено самому приложению; к нему вернёмся. sub — стабильный идентификатор пользователя, он понадобится для ABAC.
Если сервис читает только realm_access, он никогда не увидит client-роли — и откажет там, где доступ должен быть.
Шаг 1: разбор claims
Библиотека golang-jwt/jwt/v5 разбирает стандартные поля, а про realm_access ничего не знает — это формат Keycloak, не часть стандарта. Свои поля описывают структурой:
type Claims struct {
jwt.RegisteredClaims
PreferredUsername string `json:"preferred_username"`
Scope string `json:"scope"`
RealmAccess struct {
Roles []string `json:"roles"`
} `json:"realm_access"`
ResourceAccess map[string]struct {
Roles []string `json:"roles"`
} `json:"resource_access"`
}
var claims Claims
token, err := jwt.ParseWithClaims(raw, &claims, keyFunc,
jwt.WithIssuer(cfg.Issuer),
jwt.WithAudience(cfg.ClientID),
jwt.WithExpirationRequired(),
)
if err != nil || !token.Valid {
return nil, apperr.Unauthorized("UNAUTHENTICATED", "токен не принят")
}
jwt.WithAudience здесь не украшение: подпись говорит только «токен выпустил наш Keycloak», но не «он выписан нам». Токен соседнего приложения того же realm — с настоящей подписью и ролью admin внутри — пройдёт разбор, если аудиторию не проверять.
Шаг 2: переводчик — Principal в контексте
Spring переводит роли в GrantedAuthority; в Go такого понятия нет, и его заменяет обычная структура, которую кладут в контекст запроса:
package auth
type Principal struct {
Subject string
roles map[string]struct{}
scopes map[string]struct{}
}
func (p Principal) HasRole(role string) bool {
_, ok := p.roles[role]
return ok
}
func (p Principal) HasScope(scope string) bool {
_, ok := p.scopes[scope]
return ok
}
func FromClaims(c Claims, clientID string) Principal {
p := Principal{Subject: c.Subject, roles: map[string]struct{}{}, scopes: map[string]struct{}{}}
for _, r := range c.RealmAccess.Roles {
p.roles[r] = struct{}{}
}
for _, r := range c.ResourceAccess[clientID].Roles {
p.roles[r] = struct{}{}
}
for _, s := range strings.Fields(c.Scope) {
p.scopes[s] = struct{}{}
}
return p
}
type ctxKey struct{}
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, ctxKey{}, p)
}
func PrincipalFrom(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(ctxKey{}).(Principal)
return p, ok
}
Здесь принято два решения, которые стоит проговорить. Realm- и client-роли складываются в одно множество — для проверки доступа не важно, где роль лежала; важно, что она есть. И client-роли читаются только для своего client: роль admin из resource_access чужого приложения в множество не попадает, иначе менеджер соседнего сервиса станет администратором вашего.
ResourceAccess[clientID] на отсутствующем ключе вернёт нулевую структуру с пустым срезом — чтение из nil-карты в Go безопасно, и отдельной проверки не нужно. Ключ типа ctxKey{} вместо строки нужен, чтобы другой пакет не мог случайно подменить principal своим значением с тем же именем.
Префикса ROLE_ в Go нет, и целый класс ошибок Spring здесь не существует. Зато есть свой: сравнение строк точное, Admin и admin — разные роли, и имя роли в коде должно совпадать с именем в Keycloak буква в букву.
Шаг 3: где физически стоит проверка
На маршруте проверяют роль. Это ответ на вопрос «доступна ли такая ручка этому типу пользователя»; он не зависит от данных, стоит дёшево и срабатывает до похода в базу. В chi это группа маршрутов с middleware — и это же карта доступа, которую видно одним взглядом:
func RequireRole(role string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := auth.PrincipalFrom(r.Context())
if !ok {
httperr.Write(w, r, apperr.Unauthorized("UNAUTHENTICATED", "нужен токен"))
return
}
if !p.HasRole(role) {
httperr.Write(w, r, apperr.Forbidden("ACCESS_DENIED", "недостаточно прав"))
return
}
next.ServeHTTP(w, r)
})
}
}
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(verifier))
r.Group(func(r chi.Router) {
r.Use(RequireRole("customer"))
r.Post("/orders", Handle(h.CreateOrder))
r.Get("/orders/{id}", Handle(h.GetOrder))
})
r.Group(func(r chi.Router) {
r.Use(RequireRole("admin"))
r.Delete("/orders/{id}", Handle(h.DeleteOrder))
})
})
Отказ пишется через тот же httperr.Write, что и ошибки обработчиков: 401 и 403 приходят клиенту в том же формате, что и 404, — об этом статья про единый обработчик ошибок.
В сценарии проверяют то, для чего нужны данные: владение записью, её состояние, лимиты. Эту проверку нельзя поднять на маршрут: там объекта ещё нет, а читать его дважды значит проверить одно состояние, а изменить другое.
Чего делать не надо — ставить одну и ту же проверку в оба места. Через полгода правило поменяют в одном, и какое сработает, будет зависеть от того, кто кого вызвал. Роль — на входе, данные — внутри, каждая проверка в одном месте. И отдельно: обработчик сообщения из очереди и задача по расписанию через маршруты не ходят, поэтому правила, обязанные действовать всегда, живут там, где выполняется операция.
RBAC: доступ по ролям
RequireRole выше и есть RBAC: «у тебя есть роль admin → пускаем». Он отвечает на вопрос «какому типу пользователей вообще можно сюда?» — контроль на уровне действия, не записи. RBAC легко скажет «редактировать заказы может любой customer», но в принципе не способен проверить, свой ли это заказ.
ABAC: когда одной роли мало
У Ивана роль customer, и RBAC разрешает любому customer редактировать заказы. Иван открывает заказ Петра и меняет адрес доставки на свой. Роль правильная — RBAC пропустит. Доступ при этом совершенно неправильный.
ABAC принимает решение по атрибутам: кто владелец записи, кто пришёл, в каком состоянии запись. Самый частый случай — проверка владения по sub:
func (s *OrderService) ChangeAddress(ctx context.Context, id OrderID, addr Address, actor string) error {
o, err := s.orders.ByID(ctx, id)
if err != nil {
return err
}
if o.BuyerID != actor {
return apperr.Forbidden("NOT_YOUR_ORDER", "это не ваш заказ")
}
return s.orders.Save(ctx, o.WithAddress(addr))
}
В сценарий приходит строка actor, а не Principal и не токен: достать sub из контекста — работа обработчика, там веб-слой и заканчивается. Иначе сценарий привязан к HTTP и токену Keycloak, и его не вызвать ни из фоновой задачи, ни из теста без сборки фальшивого токена.
Для чтения лучше спросить базу сразу с владельцем: ByIDAndBuyer(ctx, id, actor) и pgx.ErrNoRows → 404. Тогда существование чужой записи не выдаётся, и проверка не размазывается по коду. Для списков — только фильтр в запросе (WHERE buyer_id = $1): загрузить всё и отфильтровать в Go — значит прочитать чужое, сломать постраничную выдачу и платить за это на каждом запросе.
Обход для администратора — в одном месте
Правило «у роли admin проверка владения не применяется» в коде расползается на десять if p.HasRole("admin"), и в одном из них через полгода ошибутся. Держат его там же, где живёт сама проверка:
type OrderAccess struct {
orders orderReader
audit auditLog
}
func (a OrderAccess) CanRead(ctx context.Context, id OrderID, p auth.Principal) error {
if p.HasRole("admin") {
a.audit.Record(ctx, p.Subject, "order.read", string(id))
return nil
}
ok, err := a.orders.OwnedBy(ctx, id, p.Subject)
if err != nil {
return err
}
if !ok {
return apperr.NotFound("ORDER_NOT_FOUND", "заказ не найден")
}
return nil
}
Побочная польза: тест «администратор видит чужой заказ, покупатель нет» пишется на этот тип с fake-репозиторием, без роутера и базы. Две оговорки: обход нужен не всякому административному действию (прочитать чужой заказ по жалобе — да, менять чужую карточку от чужого имени — нет), и обход всегда оставляет запись в журнале — разбор в статье про журнал действий.
RBAC и ABAC работают вместе
Их не противопоставляют, а складывают: роль отсеивает грубо и рано, владение уточняет на уровне записи. Порядок ради экономии: проверить строку в множестве стоит ничего и срабатывает до базы; дорогая проверка владельца выполняется только для тех, кто прошёл первый фильтр.
При чём тут scope
scope — не про пользователя, а про приложение, которое действует от его имени: «это приложение может читать мои заказы, но не управлять ими». Keycloak отдаёт его строкой через пробел, FromClaims разложил её в множество:
r.With(RequireScope("orders:read")).Get("/orders", Handle(h.ListOrders))
Для обычного доступа внутри своих сервисов почти всегда хватает ролей. Scope нужен в сценариях с внешними клиентами и публичным API, где важно ограничить именно то, на что согласился пользователь.
Сколько ролей заводить
Роль — ярлык, который создаётся в Keycloak парой кликов, и возникает соблазн плодить их: customer-premium, customer-trial, seller-pro, junior-admin. Через полгода два десятка ролей, и проверки превращаются в HasAnyRole на пол-экрана.
Здоровая дисциплина обратная: ролей мало и стабильно — customer, seller, admin, system для вызовов между сервисами. Новая роль — сигнал остановиться: точно ли это новый тип пользователя? «Premium» — атрибут обычного customer (есть активная подписка), и проверяется он по данным, как владение. «Junior-admin только смотрит» — набор прав внутри admin, а не новая роль. Правило: роль отвечает «кто это за пользователь», а всё, что звучит как «а ещё у него есть/нет свойства», — атрибут, территория ABAC.
Имя роли — соглашение всей системы, а не одного сервиса. Если в одном сервисе покупатель customer, а в соседнем buyer, проверка однажды не найдёт роль и откажет молча. Имена заводят один раз, в одном realm, и держат в одном месте.
Каждый маршрут обязан иметь проверку
Самое важное правило темы и то, про которое легче всего забыть. Маршрут вне защищённой группы открыт всем — и адрес вроде /admin/orders/{id}/refund сам по себе ничего не защищает: это строка. Если возврат денег зарегистрирован на корневом роутере, а не в группе с RequireRole("admin"), его инициирует любой, у кого есть токен, а если и auth.Middleware мимо — вообще любой.
В Go нет аннотаций, по которым это проверил бы ArchUnit, но есть кое-что честнее: обойти все маршруты и дёрнуть каждый без токена.
func TestEveryRouteRequiresToken(t *testing.T) {
public := map[string]bool{"GET /health/live": true, "GET /health/ready": true, "GET /metrics": true}
srv := app.NewRouter(stubDeps())
err := chi.Walk(srv, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
key := method + " " + route
if public[key] {
return nil
}
req := httptest.NewRequest(method, strings.NewReplacer("{id}", "42").Replace(route), nil)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s без токена ответил %d, ждали 401", key, rec.Code)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
chi.Walk перечисляет все зарегистрированные маршруты, и новый маршрут попадает в тест сам, без правки. Список публичных путей явный и короткий — добавить в него что-то означает осознанно открыть маршрут, и это видно на ревью. Тест ловит и забытую группу, и маршрут, по ошибке повешенный на корневой роутер.
Частые ошибки
- Ищут роли не в том claim. Realm-роли — в
realm_access.roles, client-роли — вresource_access.<clientId>.roles, и ключ — идентификатор client, а не его отображаемое имя. - Берут client-роли всех приложений.
for _, app := range c.ResourceAccessскладывает в множество роли чужих сервисов; читают только свойclientID. - Доверяют ролям без проверки подписи. Роли в JWT — текст внутри JSON.
jwt.ParseUnverifiedгодится только для отладки; в сервисе —ParseWithClaimsс ключом из JWKS. - Проверили подпись — и решили, что всё. Без
WithAudienceпройдёт токен соседнего приложения того же realm с его рольюadmin. - Сравнивают роли без учёта регистра или с пробелами. Имя в коде и в Keycloak совпадают буква в букву, и это проверяет тест на
FromClaims. - Полагаются на RBAC там, где нужен ABAC. Роль
customerне гарантирует, что заказ — его. - Кладут
Principalв контекст строковым ключом. Другой пакет с тем же ключом подменит его; ключ — свой неэкспортируемый тип.
Разбор на маркетплейсе: где RBAC, а где ABAC
Возьмём маркетплейс с ролями buyer, seller, moderator, dispute-operator, finance — пять, и это весь каталог.
| Действие | Роль решает? | Что ещё нужно проверить |
|---|---|---|
| Оформить заказ | да, buyer | ничего |
| Посмотреть свой заказ | нет | владелец заказа = sub |
| Отменить заказ | нет | владелец и статус: после отправки нельзя |
| Создать карточку товара | да, seller | ничего |
| Изменить карточку | нет | продавец карточки = sub |
| Одобрить карточку | да, moderator | ничего |
| Решить спор | да, dispute-operator | спор взят этим оператором |
| Подтвердить выплату | да, finance | сумма выше лимита — второй подтверждающий |
Действия сотрудников почти целиком закрываются ролью, действия покупателей и продавцов — почти никогда: сотрудник работает со всем потоком, покупатель — только со своим.
Отмена заказа показывает, зачем различать два отказа:
func (s *OrderService) Cancel(ctx context.Context, id OrderID, actor string) error {
o, err := s.orders.ByID(ctx, id)
if err != nil {
return err
}
if o.BuyerID != actor {
return apperr.Forbidden("NOT_YOUR_ORDER", "чужой заказ")
}
if !o.Cancellable() {
return apperr.Conflict("ORDER_IN_DELIVERY", "заказ уже в доставке, это возврат, а не отмена")
}
return s.orders.Save(ctx, o.Cancelled())
}
Первый отказ — про доступ (403), второй — про состояние (409). Покупателю в интерфейсе нужно показать разные вещи, и смешивать их в один ErrForbidden не стоит. Выплата продавцу устроена так же: роль finance — вход в проверку, а правило «второй подтверждающий не тот же человек» — атрибуты. И общее: у сотрудников права широкие, поэтому каждое их действие пишется в журнал.
Глубже: Authorization Services: когда логика доступа уезжает в Keycloakрасширенное
У Keycloak есть третий способ: Authorization Services. На client включают «Authorization», описывают ресурсы, области действий, политики и разрешения, а сервис спрашивает у Keycloak «можно ли этому пользователю cancel для order:42» и получает токен с разрешениями. Сверху лежит UMA 2.0: пользователь сам делится ресурсом с другим.
Почему для большинства сервисов на Go это не берут: логика доступа уезжает из кода в настройки другого сервиса, её не видно в ревью и не покрывают тесты сценария; проверка «владелец ли заказа» требует, чтобы Keycloak знал о каждом заказе, то есть о миллионах строк вашей базы; появляется сетевой вызов на каждое решение. Официального Go-адаптера с policy enforcer нет, остаётся ходить в его API руками — и это тоже сигнал.
Когда всё-таки берут: права настраивает администратор заказчика без выкатов (документооборот, порталы с папками), приложений много и правила обязаны быть общими, или нужен сценарий UMA. Во всех остальных случаях достаточно ролей в токене плюс ABAC в сценарии — о чём вся эта статья.
Коротко
- Аутентификация — «кто ты» (Keycloak), авторизация — «что тебе можно» (сервис). Путь роли: назначили → попала в токен → сервис проверил подпись и
aud→ разобрал claims → собралPrincipal→ сверил с правилом. - Realm-роли лежат в
realm_access.roles, client-роли — вresource_access.<clientId>.rolesтолько своего client; обе складываются в одно множествоPrincipalв контексте с неэкспортируемым ключом. - Префикса
ROLE_в Go нет, зато сравнение строк точное: имя роли в коде совпадает с Keycloak буква в букву. - RBAC —
RequireRoleна группе маршрутов chi, отказ черезhttperr.Write; ABAC — владение поsubв сценарии, куда приходит строкаactor, а не токен. - Для чтения — запрос сразу с владельцем и
404для чужого; для списков — фильтр в SQL, а не отбор после выборки; обход для администратора — в одном типеAccessс записью в журнал. - Scope — права приложения, не пользователя:
RequireScopeдля публичного API; каталог ролей короткий и стабильный, «premium» и «только смотреть» — атрибуты, не роли. - Каждый маршрут обязан быть в защищённой группе: тест через
chi.Walkдёргает все маршруты без токена и ждёт401для всего, что не в явном списке публичных. - Отказ по доступу (
403) и отказ по состоянию (409) — разные ответы; Authorization Services и UMA берут только когда права правит администратор заказчика.
Что почитать дальше
- Проверка JWT от Keycloak в Go — JWKS,
iss,audи middleware, которое кладёт principal в контекст. - RBAC и роли в Go — каталог ролей и
RequireRolesна группу маршрутов. - Realm, client, роли и пользователи в Keycloak — откуда берутся роли и как их настраивают.
- Три токена Keycloak — из чего состоит JWT и что чаще всего ломается.
- Кейс: маркетплейс — сквозной пример, на котором разобраны роли, владение и статусы.