← назад к разделу

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 берут только когда права правит администратор заказчика.

Что почитать дальше