·
Один прокси, все авторизационные решения
Как 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 --> 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 -- no --> R403C["403"]
G -- yes --> H{"Step-up condition met\nand ACR/auth_time satisfy it?"}
H -- no --> R401C["401 + WWW-Authenticate"]
H -- yes --> I["Apply inject rules\nheader / query / body"]
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 нельзя отозвать, и единственная схема, где можно. - Права доступа. Грубый RBAC: разрешает ли хоть одна роль (или, для сервисного токена, permission клиента) вызов именно этого эндпоинта.
- Audience. Выпущен ли этот токен именно для этого ресурса.
- CEL-правило allow. Булево выражение на эндпоинт для всего, что RBAC выразить не может.
- Step-up (RFC 9470). Нужна ли этому запросу более сильная или более свежая аутентификация, чем имеет токен.
- Inject. Переписать заголовки, query-параметры или поля тела перед проксированием — чаще всего, чтобы проставить идентичность пользователя в исходящий запрос.
Шаги с третьего по шестой настраиваются на уровне эндпоинта, в 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"
Проверка идет после прав доступа, audience и правила allow намеренно: вызывающий, которому этот эндпоинт вообще не разрешен, получает окончательный 403, а не приглашение пройти step-up, которое привело бы его через свежий OTP ровно к тому же отказу. Когда клиент перенаправляется через /authorize с запрошенным acr_values, auth разрешает его по тому, что у пользователя реально зарегистрировано — пасскей, телефон для OTP, — и, если это недостижимо, сразу проваливает authorization request с unmet_authentication_requirements, вместо того чтобы дать клиенту застрять в петле между challenge, который никогда не будет удовлетворен, и страницей входа.
Слой 4: подстановка, чтобы upstream-сервис не разбирал токен сам
Каждое выражение allow и stepUpCondition видит один и тот же CEL-контекст, и inject использует его в третий раз, чтобы переписать исходящий запрос:
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 перезаписывает то, что туда прислал клиент. Подставленные значения всегда заменяют присланные клиентом; именно этот порядок делает подстановку безопасной для идентичности, а не просто удобной.
Внутренние ресурсы идут еще дальше: 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. Все это попадает под один ключ 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 в том же трейсе. Отказ на пятом шаге и медленный ответ реального ресурса оказываются в одном трейсе, так что вопрос “почему этот запрос занял 800мс” не требует вручную сопоставлять логи двух сервисов.
Поскольку все семь проверок выполняются внутри одного и того же middleware, ничто из этого не опционально по сервисам. Ресурс, который добавит stepUpCondition на следующей неделе, автоматически получит step_up_required в своих метриках на следующей неделе — точно так же, как уже получает access_rule_denied для своего существующего правила allow.
Что не помещается в один хоп
Прокси видит только то, что есть в запросе. JSON-тело, ограниченный набор заголовков, query-параметры, сегменты пути. Решение, которому нужна история транзакций вызывающего или фрод-скор, посчитанный внутри сервиса по данным, до которых edge не дотягивается, CEL-выражением не станет — эту проверку по-прежнему держит сервис, и отказ он возвращает сам.
Изменения прав отстают на интервал обновления кэша. Отзыв токена мгновенный, потому что проталкивается. Отзыв права — снятие orders:write с роли — доходит до каждой реплики edge за время до одного интервала обновления. Это разумный компромисс для того, как редко меняется структура ролей, но это реальный компромисс, и он неверен для всего, что должно быть запрещено немедленно.
Отказоустойчивый в сторону запрета CEL может провалиться на опечатке. Выражение, ссылающееся на поле, которого нет в конкретном запросе, вычисляется как false, а не как ошибка, и вызывающий все равно получает обычный 403. Ключ cel делает такой случай различимым постфактум, а не молчаливым, но сам по себе в алерт его не превращает, а валидация на этапе конфигурации не поймает заранее: она проверяет, что выражение парсится и возвращает булево значение, а не то, что “это поле иногда отсутствует”.
Inject перезаписывает, а не объединяет. Подстановка в тело, целящаяся в поле, которое клиент тоже прислал, полностью его заменяет. Это верное поведение для идентичности (userId никогда не должен управляться атакующим) и неверная форма для всего, что должно складываться.
Один прокси — это и один радиус поражения. Централизация решения означает, что баг в edge затрагивает каждый ресурс за ним, а не один сервис. Фиксированный и общий пайплайн из семи шагов — это то, что делает его проверяемым в одном месте; это же делает регрессию здесь дороже, чем регрессию в middleware одного сервиса.
Частые вопросы
Почему не сделать все это через OPA-сайдкар в каждом сервисе? Можно, и это решает проблему “N копий” тем же способом. Чего это не дает бесплатно — того, что запрос уже находится на горячем пути прокси с провалидированным токеном и проверенным отзывом; перед сайдкаром все равно понадобится что-то, что это делает, либо каждый сайдкар будет перепроверять токен сам. К тому же версионирование сайдкара и его обновление — дополнительная головная боль.
Видит ли CEL-контекст сырой client secret или refresh-токен? Нет. token — это десериализованные клеймы access-токена, никогда не сырой credential; собственный исходящий секрет ресурса edge хранит отдельно и никогда не передает в выражение.
Могут ли allow и stepUpCondition ссылаться на результат друг друга? Нет, это независимые проверки над одним контекстом, выполняемые в фиксированном порядке (allow перед stepUp). Условие step-up может ссылаться на все то же, что и allow, но не на булев результат самого allow.
Что будет с кардинальностью метрик, если добавить много эндпоинтов? Каждый зарегистрированный шаблон эндпоинта — отдельная метка маршрута, так что метрики масштабируются с числом зарегистрированных эндпоинтов, а не с числом различных запросов. Сто эндпоинтов — это сто рядов; миллион запросов к одному эндпоинту — все еще один.