Скоупы
Что ключу разрешено делать.
Словарь
Закрытый набор вида resource:action. Достаточно маленький, чтобы показать человеку списком галочек, и достаточно стабильный, чтобы сохранённая выдача означала то же самое и год спустя. На том же словаре написана РОЛЬ рабочего пространства, и он же ограничивает инструменты MCP, поэтому клиент «только на чтение» даже не увидит инструмент отправки. Один алфавит, три поверхности.
| Скоуп | Что даёт |
|---|---|
| emails:send | Отправлять письма |
| emails:read | Читать отправленные сообщения и их статус доставки |
| drafts:read | Читать черновики |
| drafts:write | Создавать и редактировать черновики |
| threads:read | Читать цепочки и сообщения |
| threads:write | Помечать, отмечать прочитанными и архивировать цепочки |
| labels:read | Читать метки |
| labels:write | Создавать и редактировать метки |
| contacts:read | Читать контакты |
| contacts:write | Добавлять, редактировать и удалять контакты |
| audiences:read | Читать аудитории и их состав |
| audiences:write | Создавать и редактировать аудитории и менять их состав |
| calendar:read | Читать события календаря и приглашения |
| calendar:write | Создавать и изменять события календаря и отвечать на них |
| templates:read | Читать шаблоны писем и просматривать их |
| templates:write | Создавать, редактировать шаблоны писем и отправлять с ними |
| domains:read | Читать домены и их статус DNS |
| domains:write | Подтверждать и настраивать домены |
| webhooks:read | Читать эндпоинты вебхуков и их доставки |
| webhooks:write | Создавать, редактировать и тестировать вебхуки |
| rules:read | Читать почтовые правила и тестировать их |
| rules:write | Создавать, редактировать и переупорядочивать почтовые правила |
| connections:read | Читать, какие почтовые ящики подключены |
| members:read | Видеть, кто состоит в рабочем пространстве и какими правами обладает |
| members:write | Добавлять и удалять людей и менять то, до чего они могут дотянуться |
| roles:read | Читать роли, которые определяет это рабочее пространство |
| roles:write | Создавать, редактировать и удалять роли |
| settings:read | Читать настройки почтового ящика, включая подпись |
| settings:write | Менять настройки почтового ящика и подпись |
| keys:write | Заменять собственный секрет без входа кого-либо в консоль |
Ключ, созданный без продуманного списка скоупов, получает emails:send и больше ничего. Безопасное значение по умолчанию для учётных данных — самое узкое, при котором они ещё полезны.
Ключ ограничен ролью, стоящей за ним
Ключ может быть выпущен под РОЛЬЮ, и роль — это потолок, а не вторая выдача прав. То, что ключ на самом деле может делать, — это его собственные скоупы, ПЕРЕСЕЧЁННЫЕ с разрешениями этой роли (key.scopes ∩ role.permissions), вычисленные один раз на границе, на каждом запросе, до того как дело дойдёт до любого эндпоинта. Ниже по стеку никто не знает, что роли существуют: скоупа, которого нет у роли, просто нет в списке, который читают проверки скоупов.
Поэтому оба списка читаются вместе и ни один сам по себе не побеждает. Ключ с emails:send под ролью, у которой этого разрешения нет, отправлять не может; роль с emails:send ничего не даёт ключу, который никогда его не просил. Отметить скоуп — значит запросить полномочие, а роль решает, сколько из запрошенного вы получите.
У ключа БЕЗ роли потолка нет, а значит, он так же широк, как рабочее пространство, для которого выпущен. Именно это несёт каждый ключ, созданный до появления ролей, и именно это получает владелец, если не трогает это поле, — так что null в роли это САМОЕ ШИРОКОЕ состояние ключа, а не самое узкое. Поэтому же при удалении роли требуется сказать, куда денутся её ключи: оставить их сиротами значило бы тихо повысить в правах каждый из них.
Пересечение вычисляется на каждый запрос, а не отпечатывается на ключе в момент выпуска. Благодаря этому сужение роли становится живым отзывом доступа, действующим на следующем же вызове без ротации ключа, а расширение — точно так же живым, и об этой половине стоит помнить.
GET /ping и GET /keys/self сообщают scopes рядом с grantedScopes и roleId ради одного конкретного сбоя. scopes — это действующий список и единственный, который что-либо разрешает; grantedScopes — это то, с чем ключ был выпущен. Всё, что есть во втором и отсутствует в первом, забрала роль, и эта разница — исчерпывающий ответ на «у моего ключа есть emails:send, а я получаю insufficient_scope». Исправляется это изменением роли, а не ещё одним ключом.
curl "$OE/ping" -H "$AUTH" { "ok": true, "keyId": "4c1b257a66287fd113bd89d0", "mode": "live", "scopes": ["emails:read", "threads:read"], "roleId": "role_c40a95f21cc65d31c2a89e07", "grantedScopes": ["emails:send", "emails:read", "threads:read"], "workspaceId": "10417196-e324-4283-af98-66ec62167c47"}Пять разрешений не могут попасть на ключ вообще: api-keys:read, api-keys:write, billing:read, billing:write и workspace:manage. Это разрешения, но не скоупы, поэтому никакая, сколь угодно щедрая, роль не поместит их в токен: выпустить ещё один ключ, изменить права другого ключа или сменить тариф может только человек, вошедший в систему. Единственное, что ключ может сделать с самим собой, — заменить собственный секрет, за скоупом keys:write. GET /roles/permissions помечает эти пять как scope: false, и именно это позволяет одному компоненту отрисовывать и матрицу ролей, и список галочек при создании ключа.
roles:write фактически равен всему словарю, и делать вид, что это не так, было бы более опасной документацией. Ключ с этим разрешением может выполнить PATCH той самой роли, которая его ограничивает, и выдать себе всё остальное, а поскольку потолок вычисляется на каждый запрос, более широкий вариант применится на следующем же вызове. Это не дыра, которую надо заткнуть, ведь редактор ролей, который не может редактировать роли, — не редактор ролей. Это причина не ставить roles:write на ключ, которому нужно было лишь читать список участников.
Область отправки
Отдельно от скоупов ключ можно сузить в том, от чьего имени он может отправлять. Он несёт два списка. domainAllowlist содержит целые домены, и ключ с доменом может отправлять от любого адреса на нём, включая адреса, созданные после самого ключа. addressAllowlist содержит отдельные адреса. Оставьте оба пустыми — и ключ будет так же широк, как рабочее пространство, но никогда шире. GET /keys/self показывает оба списка, а GET /addresses сообщает, чем данный ключ действительно может пользоваться, и это ответ на необъяснимый from_address_forbidden.
Тот же набор сужает и то, что ключ читает. Отправленная почта, трекинг и календарь отвечают только по адресам, от имени которых ключ может отправлять, так что ключ, ограниченный одним доменом, не отправляет и не читает от имени другого. Целый домен также позволяет ключу задать хост трекинга этого домена, чего ключ, ограниченный отдельными адресами, сделать не может.
Итого три сужения, и они складываются, а не перекрывают друг друга: скоупы на ключе, разрешения роли над ним и домены с адресами, которые он может поставить в заголовок From. Отправке нужны все три, а отказ называет только первое, на которое она наткнулась.