· · обновлено
Один прокси, все авторизационные решения
Как edge-прокси Versola сводит права доступа, динамические CEL-правила, step-up по RFC 9470 и подстановку идентичности в один упорядоченный пайплайн перед каждым сервисом — и один слой наблюдаемости на все это.
Откройте три сервиса в типичном бэкенде — и почти всегда найдете три копии одного и того же абзаца: достать токен, проверить роль, проверить скоуп, может быть проверить, что вызывающий владеет ресурсом, к которому обращается, и только потом пропустить запрос. Начиналось это как один абзац, скопированный трижды. Через год один из них проверяет body.total на лимит суммы, а два других нет, потому что тот, кто писал третий эндпоинт, просто не знал о правиле.
Это расхождение — не вопрос дисциплины. Это то, что происходит, когда одно и то же решение реализовано N раз. edge в Versola существует, чтобы решение принималось один раз: каждый запрос к каждому внутреннему ресурсу проходит через него, и к моменту, когда запрос доходит до вашего сервиса, права доступа, бизнес-правила, step-up и идентичность уже разрешены.
Этот пост проходит по всему пайплайну в том порядке, в котором его реально исполняет edge. Справочник по полям лежит в документации: Политики эндпоинтов.
Пайплайн
Девять проверок выполняются в фиксированном порядке для каждого проксируемого запроса. Любая из них может прервать запрос; все, что после нее, просто не запускается.
flowchart TD
A["Request arrives\nBearer token or session cookie"] --> B{"Token valid\nand unexpired?"}
B -- "no, cookie session" --> B2["Refresh via stored\nrefresh token"]
B -- "no, bearer header" --> R401A["401"]
B2 --> C
B -- yes --> C{"Revoked?\nConcurrentHashMap.get"}
C -- yes --> R401B["401"]
C -- no --> T{"Token binding OK?\nDPoP proof, cnf rules"}
T -- no --> R401T["401"]
T -- yes --> L{"Resource and endpoint\nregistered?"}
L -- no --> R404["404"]
L -- yes --> D{"Permission grants\nthis endpoint?"}
D -- no --> R403A["403"]
D -- yes --> E{"Audience matches\nresource?"}
E -- no --> R403B["403"]
E -- yes --> F["Fetch /userinfo\n(only if endpoint asks)"]
F --> G{"CEL allow rule\ntrue?"}
G -- "false or data missing" --> R403C["403"]
G -- yes --> H{"Step-up needed and\nACR/auth_time unmet?\n(missing data counts as needed)"}
H -- yes --> R401C["401 + WWW-Authenticate"]
H -- no --> I["Build upstream request,\nrun inject rules\nheader / query / body"]
I -- "data missing" --> R403D["403"]
I --> J["Proxy to upstream\nwith edge's own credential or caller's token"]
- Валидность токена. Подпись, срок действия, issuer. У истекшей cookie-сессии есть одна попытка обновления через сохраненный refresh-токен, прежде чем упасть; у истекшего bearer-заголовка такой попытки нет, потому что обновлять нечего.
- Отзыв. Поиск в
ConcurrentHashMapпо id токена, публичному id сессии и subject, актуальность которого держит PostgresLISTEN/NOTIFY. Ни одного запроса к базе в горячем пути. Подробно — в статье почему JWT нельзя отозвать, и единственная схема, где можно. - Привязка токена. Если токен привязан к ключу DPoP или к сертификату, запрос должен доказать, что владеет им. Непривязанный токен с DPoP-proof тоже отклоняется.
- Поиск ресурса и эндпоинта. Сначала ресурс, затем эндпоинт по методу и пути. Это стоит после аутентификации намеренно: вызывающий без валидного токена получает 401, существует путь или нет, так что 404 не рисует посторонним карту ваших эндпоинтов.
- Права доступа. Грубый RBAC: разрешает ли хоть одна роль (или, для сервисного токена, permission клиента) вызов именно этого эндпоинта.
- Audience. Выпущен ли этот токен именно для этого ресурса.
- CEL-правило allow. Булево выражение на эндпоинт для всего, что RBAC выразить не может.
/userinfoзагружается прямо перед ним, и только если эндпоинт это запрашивает. - Step-up (RFC 9470). Нужна ли этому запросу более сильная или более свежая аутентификация, чем имеет токен.
- Inject. Переписать заголовки, query-параметры или поля тела на выходе — чаще всего, чтобы проставить идентичность пользователя в исходящий запрос. Правила работают в момент сборки запроса к upstream, поэтому запрос, не прошедший step-up, до них не доходит.
Шаги с пятого по девятый настраиваются на уровне эндпоинта, в central, тем, кто владеет ресурсом. Ничего из этого не зашито в код защищаемого сервиса.
Слой 1: права доступа — это грубый фильтр
Права доступа отвечают на один вопрос: разрешено ли этой роли или этому клиенту вообще вызывать этот эндпоинт. Они разрешаются из кэша в памяти, построенного из трех связей — роль → permission, permission → эндпоинт, клиент → permission, — обновляемых по интервалу:
override def getAllowedEndpointsForRoles(tenantId: TenantId, roles: List[RoleId]): UIO[Set[ResourceEndpointId]] =
for
roleMap <- rolesCache.get
permMap <- permissionsCache.get
permIds = permissionsFor(tenantId, roles, roleMap)
yield permIds.flatMap(permMap.getOrElse(_, Set.empty))
Отказ по правам — это 403 еще до того, как токен проверен на остальные требования эндпоинта. Это намеренно самый дешевый и наименее информативный отказ: он ничего не говорит вызывающему о том, что эндпоинту на самом деле нужно.
Слой 2: CEL для правила, которое RBAC выразить не может
Роль отвечает “может ли этот пользователь вызвать POST /orders”. Она не отвечает на вопрос “этот конкретный заказ принадлежит ему” или “эта конкретная сумма в пределах его лимита”, потому что это зависит от запроса, а не от роли. Для этого и существует поле allow — выражение CEL, вычисляемое на каждый запрос над тремя корневыми переменными:
| Корень | Содержимое |
|---|---|
token | клеймы провалидированного access-токена |
user | клеймы /userinfo, загружаются только если эндпоинт их запрашивает |
request | path.params, query, headers и body, когда запрос в JSON |

Развернутый на скриншоте эндпоинт содержит request.body.total <= 50000 && user.subscription == 'premium', поверх уже наложенного права orders:write. Права доступа решают, кто может зайти в комнату; CEL решает, что им можно, раз они уже там.
Программы компилируются заранее, при загрузке кэша правил, а не на первом запросе, который до них доходит, так что ни один запрос не платит за компиляцию.
Слой 3: step-up, когда правило не про permission, а про силу и актуальность аутентификации
Часть решений — не “может ли вызывающий это делать”, а “доказал ли вызывающий, кто он, достаточно недавно, чтобы это делать”. Перевод до тысячи проходит на парольной сессии; выше — по RFC 9470 нужна более сильная, явно заданная аутентификация.
За это отвечают еще три поля в конфиге эндпоинта: stepUpCondition, stepUpAcr и maxAge:

{
"id": 501,
"method": "POST",
"path": "/payments/transfer",
"allow": "request.body.payment <= user.dailyTransferLimit",
"inject": [{ "target": "body", "name": "userId", "expression": "token.sub" }],
"stepUpCondition": "request.body.payment > 100000",
"stepUpAcr": "otp",
"maxAge": 600
}
stepUpCondition — это CEL над тем же контекстом, что и allow. Когда оно истинно, stepUpAcr становится обязательным; maxAge проверяется независимо, так что эндпоинт может требовать максимальный возраст сессии вообще без условного ACR. Запрос, проваливший любое из условий, получает один 401, несущий оба:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="otp", max_age="600"
Условие, которое читает отсутствующие в запросе данные, считается истинным, и step-up требуется. Условие решает, нужна ли более сильная аутентификация, и выражение, которое не может ответить ни так ни так, не должно пропускать слабый токен. Проверка идет после прав доступа, audience и правила allow намеренно: вызывающий, которому этот эндпоинт вообще не разрешен, получает окончательный 403, а не приглашение пройти step-up, которое привело бы его через свежий OTP ровно к тому же отказу. Когда клиент перенаправляется через /authorize с запрошенным acr_values, auth разрешает его по тому, что у пользователя реально зарегистрировано — пасскей, телефон для OTP, — и, если это недостижимо, сразу проваливает authorization request с unmet_authentication_requirements, вместо того чтобы дать клиенту застрять в петле между challenge, который никогда не будет удовлетворен, и страницей входа.
Слой 4: подстановка, чтобы upstream-сервис не разбирал токен сам
Каждое выражение allow и stepUpCondition видит один и тот же CEL-контекст, и inject использует его в третий раз, чтобы переписать исходящий запрос. Правила выполняются последними, пока собирается запрос к upstream:
val grouped = endpoint.inject.groupBy(_.target)
val headerInjects = grouped.getOrElse(InjectTarget.header, Vector.empty)
val queryInjects = grouped.getOrElse(InjectTarget.query, Vector.empty)
val bodyInjects = grouped.getOrElse(InjectTarget.body, Vector.empty)
Оба скриншота выше подставляют userId из token.sub в тело запроса. Upstream-сервис никогда не видит токен вызывающего, никогда не проверяет подпись и ничего не знает о ротации JWK и JWKS — он читает userId из тела как любое другое поле, потому что edge перезаписывает то, что туда прислал клиент. Подставленные значения всегда заменяют присланные клиентом; именно этот порядок делает подстановку безопасной для идентичности, а не просто удобной.
Есть две детали, на которых спотыкаются. Подставленные значения всегда строки: число или булев результат проходит через toString, так что userId приедет в теле как "42", а не 42. А правило, вычислившееся в null, ничего не добавляет, но значение клиента с этим именем все равно удаляется. Если правило должно быть необязательным, пусть возвращает пустую строку. Правило, которое читает отсутствующие в запросе данные, ни на что не откатывается: запрос получает 403 с inject_rule_denied.
Внутренние ресурсы идут еще дальше: edge меняет токен вызывающего на собственный credential Authorization: Basic для этого ресурса, так что upstream-сервис, логирующий входящие заголовки, никогда не захватит access-токен пользователя.
Наблюдаемость: пятая вещь, общая для всех слоев
Ни один из первых четырех слоев не стоил бы централизации, если бы отказы в них были невидимы. Каждый запрос получает один span, одну строку лога и общий набор метрик — независимо от того, какой слой его остановил.
Маршруты помечены шаблоном, а не путем. /resources/orders-api/orders/{id} — это один ряд метрик и одно имя span вне зависимости от того, сколько разных id заказов было запрошено: инструментирование рендерит зарегистрированный шаблон, а не совпавший запрос, так что параметры пути не могут взорвать кардинальность.
Каждый слой отчитывается через один и тот же словарь. checkPermissions ставит permission_denied, checkAudience — audience_denied, checkRules — access_rule_denied или access_rule_failed, checkStepUp — step_up_required или step_up_condition_failed, а правила inject — inject_rule_denied или inject_rule_failed. Все это попадает под один ключ error в строке лога запроса:
def setError(code: String, description: Option[String] = None): UIO[Unit] =
logContext.update(_.annotate(error, ErrorDetails(code, description)))
Закрытый, стабильный словарь — это то, что превращает “алертить, когда access_rule_denied подскакивает для ресурса X” из грепа по свободному тексту в запрос. code именно поэтому остается закрытым; description — это место, где проверка добавляет свободный текст, когда ей есть что сказать, — именно так отказ из-за отсутствующего в запросе значения остается отличим от правила, чисто вычислившегося в false, без отдельного кода под это.
Идентичность прикрепляется по ходу запроса и доживает до финальной строки лога. Как только токен провалидирован, edge аннотирует контекст лога id токена, subject и публичный id сессии. Каждая следующая строка лога этого запроса — включая ту единственную строку, что пишется при отправке ответа, — несет их, без того чтобы какой-либо слой явно их прокидывал:
private def logAccessTokenClaims(claims: AccessTokenClaims): UIO[Unit] =
Observability.setToken(claims.jti) *>
Observability.setUserId(claims.subject) *>
ZIO.foreachDiscard(claims.sid)(Observability.setSessionId)
Трейсы пересекают границу прокси, а не обрываются на ней. Контекст трейса входящего запроса извлекается и используется для открытия серверного span; исходящий запрос к upstream-сервису открывает клиентский span в том же трейсе. Отказ на правиле allow и медленный ответ реального ресурса оказываются в одном трейсе, так что вопрос “почему этот запрос занял 800мс” не требует вручную сопоставлять логи двух сервисов.
Поскольку все эти проверки выполняются внутри одного и того же middleware, ничто из этого не опционально по сервисам. Ресурс, который добавит stepUpCondition на следующей неделе, автоматически получит step_up_required в своих метриках на следующей неделе — точно так же, как уже получает access_rule_denied для своего существующего правила allow.
Что не помещается в один хоп
Прокси видит только то, что есть в запросе. JSON-тело, ограниченный набор заголовков, query-параметры, сегменты пути. Решение, которому нужна история транзакций вызывающего или фрод-скор, посчитанный внутри сервиса по данным, до которых edge не дотягивается, CEL-выражением не станет — эту проверку по-прежнему держит сервис, и отказ он возвращает сам.
Изменения прав отстают на интервал обновления кэша. Отзыв токена мгновенный, потому что проталкивается. Отзыв права — снятие orders:write с роли — доходит до каждой реплики edge за время до одного интервала обновления. Это разумный компромисс для того, как редко меняется структура ролей, но это реальный компромисс, и он неверен для всего, что должно быть запрещено немедленно.
Отказоустойчивый в сторону запрета CEL может провалиться на опечатке. Выражение, ссылающееся на поле, которого нет в конкретном запросе, — это не ошибка и не false, а отсутствующие данные, и каждая проверка трактует их по-своему. В allow это 403 (access_rule_denied с описанием, отличающим такой случай от честного false). В правиле inject — 403 с inject_rule_denied. В условии step-up это считается истиной, и вызывающего просят пройти аутентификацию заново. Необязательные значения защищайте через has(). Коды ошибок делают такие случаи различимыми постфактум, а не молчаливыми, но сами по себе в алерт их не превращают, а валидация на этапе конфигурации не поймает заранее: она проверяет, что выражение парсится и возвращает нужный тип, а не то, что “это поле иногда отсутствует”.
Inject перезаписывает, а не объединяет. Подстановка в тело, целящаяся в поле, которое клиент тоже прислал, полностью его заменяет. Это верное поведение для идентичности (userId никогда не должен управляться атакующим) и неверная форма для всего, что должно складываться.
Один прокси — это и один радиус поражения. Централизация решения означает, что баг в edge затрагивает каждый ресурс за ним, а не один сервис. Фиксированный и общий пайплайн — это то, что делает его проверяемым в одном месте; это же делает регрессию здесь дороже, чем регрессию в middleware одного сервиса.
Частые вопросы
Почему не сделать все это через OPA-сайдкар в каждом сервисе? Можно, и это решает проблему “N копий” тем же способом. Чего это не дает бесплатно — того, что запрос уже находится на горячем пути прокси с провалидированным токеном и проверенным отзывом; перед сайдкаром все равно понадобится что-то, что это делает, либо каждый сайдкар будет перепроверять токен сам. К тому же версионирование сайдкара и его обновление — дополнительная головная боль.
Видит ли CEL-контекст сырой client secret или refresh-токен? Нет. token — это десериализованные клеймы access-токена, никогда не сырой credential; собственный исходящий секрет ресурса edge хранит отдельно и никогда не передает в выражение.
Могут ли allow и stepUpCondition ссылаться на результат друг друга? Нет, это независимые проверки над одним контекстом, выполняемые в фиксированном порядке (allow перед stepUp). Условие step-up может ссылаться на все то же, что и allow, но не на булев результат самого allow.
Что будет с кардинальностью метрик, если добавить много эндпоинтов? Каждый зарегистрированный шаблон эндпоинта — отдельная метка маршрута, так что метрики масштабируются с числом зарегистрированных эндпоинтов, а не с числом различных запросов. Сто эндпоинтов — это сто рядов; миллион запросов к одному эндпоинту — все еще один.