Введение и аутентификация
Базовый URL, получение токена, роли и права, формат ошибок, постраничная выдача и потоки событий.
REST API контрол-плейна — то же самое, чем пользуется веб-админка: в системе нет действия, доступного в интерфейсе и недоступного в API. Через него делают массовый завод камер, ночную выгрузку архива, интеграцию со СКУД и системами учёта, внешний мониторинг флота.
Эта страница описывает то, что одинаково для всех маршрутов: адрес, токен, роли, коды ошибок, постраничную выдачу и потоки SSE. Разбор конкретных маршрутов — в соседних разделах, начиная с Камеры.
Базовый URL#
https://cctv.example.com/api/v1
- Схема — только HTTPS. Единственное исключение — режим
http, когда перед Plane стоит реверс-прокси, который и завершает TLS; наружу всё равно смотрит HTTPS. - Хост и порт — те же, что у админки. Отдельного порта под API нет.
- Тело запроса и ответа — всегда JSON в UTF-8. Для запросов с телом обязателен заголовок
Content-Type: application/json.
Во всех примерах ниже используются две переменные оболочки:
BASE=https://cctv.example.com # адрес вашего Plane, без завершающего слэша TOKEN=… # токен сессии, получение — ниже
curl -sS -o /dev/null -w "%{http_code}\n" "$BASE/api/v1/version" # 401 — сервис работает и требует авторизации. Это ожидаемый ответ без токена.
Ответ 000 означает, что до сервиса не дошли по сети, 502/504 — проблема на реверс-прокси.
Аутентификация#
POST /api/v1/auth/login#
Без токена — единственный маршрут /api/v1, не требующий
авторизации.
Назначение — обменять логин и пароль на подписанный токен сессии. Plane при этом создаёт запись
сессии в базе, обновляет last_login_at пользователя и пишет запись аудита auth.login.
Тело запроса
| Поле | Тип | Обязательное |
|---|---|---|
login |
строка | да |
password |
строка | да |
Пример curl — рабочий шаблон, которым начинается любой сценарий в этой документации:
BASE=https://cctv.example.com TOKEN=$(curl -sS -X POST "$BASE/api/v1/auth/login" \ -H 'Content-Type: application/json' \ -d '{"login":"integration","password":"<ПАРОЛЬ>"}' \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])') # проверка: непустая строка вида eyJhbGciOi... echo "${TOKEN:0:16}…"
Пример ответа
{ "token": "eyJhbGciOi…", "expires_at": "2026-08-19T12:00:00Z", "jti": "<UUID>", "sub": "<UUID>", "org": "<UUID>", "role": "org_admin" }
sub — идентификатор пользователя, org — его организация, jti — идентификатор сессии (он же
попадает в аудит и в списки отзыва). Роль и организация в теле ответа даны для удобства интерфейса;
полагаться на них как на источник истины не нужно — см. ниже.
Ошибки
| Код | Тело | Причина |
|---|---|---|
401 |
{"error":"bad_credentials"} |
неизвестный логин, неверный пароль или отключённая учётная запись — эти случаи намеренно неразличимы |
429 |
{"error":"throttled"} |
этот IP временно заблокирован после серии неудач |
503 |
{"error":"db_unavailable"} |
база или обработчик паролей недоступны |
Примечания
- Защита от перебора: 8 неудачных попыток с одного IP за 60 секунд блокируют этот IP на
60 секунд; успешный вход счётчик обнуляет. IP берётся из первого узла
X-Forwarded-For, если заголовок есть, иначе из TCP-соединения. Скрипт, который в цикле пробует пароли, заблокирует сам себя — вставляйте паузу и не запускайте вход параллельно из нескольких процессов. - В журнал аудита пишутся
auth.login,auth.login_failed(с указанием введённого логина),auth.lockoutиauth.session_evicted.
Заголовок авторизации#
Все остальные маршруты /api/v1 требуют заголовок:
curl -sS "$BASE/api/v1/cameras?limit=50" \ -H "Authorization: Bearer $TOKEN"
Ни cookie-авторизации, ни API-ключей для этих маршрутов нет. Модульные токены существуют, но
открывают только приём событий (POST /api/v1/events) и описаны в разделе
Настройки Plane и токены.
При каждом запросе Plane заново проверяет подпись токена, наличие живой (не отозванной и не
просроченной) сессии, а затем перечитывает пользователя из базы. Поля role и org внутри
токена — только подсказка для интерфейса. Практическое следствие: понижение роли, отключение
учётной записи или отзыв сессии действуют со следующего же запроса, а не после истечения токена.
Срок жизни токена#
expires_at в ответе на вход — это текущее время + session_ttl_secs, значение задаётся на весь
Plane в plane.toml (см. plane.toml — все параметры). После этого
момента любой запрос вернёт 401 unauthorized.
Долгоживущей интеграции не нужно логиниться заново: обновляйте текущую сессию.
POST /api/v1/session/refresh#
Любой вошедший
Назначение — переподписать существующую сессию: тот же идентификатор сессии (jti), новый
срок действия, заново прочитанные из базы роль, организация и гранты. Пользуйтесь этим, чтобы
держать долгую интеграцию живой и чтобы подхватить изменение прав без повторного входа.
Параметры пути, параметры запроса, тело — отсутствуют. Токен берётся из заголовка.
Пример curl
TOKEN=$(curl -sS -X POST "$BASE/api/v1/session/refresh" \ -H "Authorization: Bearer $TOKEN" \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
Пример ответа
{ "token": "eyJhbGciOi…", "expires_at": "2026-08-19T12:00:00Z" }
Ошибки
| Код | Тело | Причина |
|---|---|---|
401 |
{"error":"evicted"} |
сессия отозвана: её вытеснил чужой вход, сработала админская операция безопасности или была ротация ключа подписи |
401 |
{"error":"unauthorized"} |
токена нет, он повреждён, уже просрочен, сессия неизвестна, пользователь удалён или отключён |
503 |
{"error":"db_unavailable"} |
база недоступна |
Примечания
- Идентификатор сессии
jtiсохраняется — записи аудита и списки отзыва на нодах остаются корректными. - Обновление делает сессию самой свежей, поэтому она переживает вытеснение по лимиту сессий.
- Различие
evictedиunauthorizedсделано специально: клиент может отличить «меня выкинули» от «токен просто негоден» и по-разному сообщить об этом оператору. - Обновляйте токен заранее — например, за 10 % до
expires_at, а не по факту получения401.
Роли и права#
Ролей три. Роль хранится у пользователя и определяет доступ к административным маршрутам; у оператора доступ к камерам дополнительно ограничен грантами.
| Роль | В API | Что может |
|---|---|---|
| Суперадминистратор | superadmin |
всё во всей установке: организации, ноды, лицензии, перенос объектов между организациями, выдача роли суперадмина |
| Администратор организации | org_admin |
всё внутри своей организации: камеры, пользователи, теги, шаблоны, мозаики, ссылки доступа |
| Пользователь (оператор) | user |
только то, что выдано грантами: просмотр, архив, события, PTZ, экспорт |
Суперадминистратор. Живёт только в организации по умолчанию. Последний включённый
суперадминистратор защищён: его нельзя удалить, отключить или понизить в роли — попытка вернёт
409 last_superadmin. Только суперадминистратор может создавать и редактировать организации,
переносить пользователя, тег, камеру или шаблон в другую организацию и выдавать роль
superadmin.
Администратор организации. Видит и меняет всё в своей организации, но не может изменить саму
организацию — квоты и лимиты редактирует только суперадминистратор. Не может ни создать
суперадминистратора, ни как-либо его затронуть (403 superadmin_is_superadmin_only).
Оператор. Административные списки (GET /api/v1/cameras, /users, /tags) для него закрыты —
403 forbidden. Ему доступны: собственная учётная запись и смена своего пароля, компактный список
камер GET /api/v1/cameras/lite, мобильная витрина /api/v1/mobile/*, дескрипторы просмотра и
архива, PTZ и экспорт — в объёме выданных бит привилегий.
Биты привилегий#
Грант — это маска привилегий, выданная на организацию целиком или на конкретный тег. Итоговое право оператора на камеру — объединение (побитовое ИЛИ) масок всех гранта-меток, которые несёт камера: её организация и её теги.
| Бит | Значение | Что открывает |
|---|---|---|
LIVE |
1 | живое видео |
DVR |
2 | воспроизведение архива |
EVENTS |
4 | просмотр событий |
PTZ |
8 | управление поворотом и зумом |
EXPORT |
16 | выгрузка фрагмента архива |
PREVIEW |
32 | статичный кадр-превью |
Биты независимы: просмотр архива не подразумевает выгрузку, а превью не подразумевает живое видео.
Полная маска — 63 (0x3f). Работа с грантами описана в разделе
Организации и пользователи и
Пользователи и права доступа.
Сужение области видимости: ?org=#
Многие административные списки принимают ?org=<UUID>.
- Суперадминистратор без
?org=видит всю установку, с?org=— одну организацию. - Любая другая роль, указавшая чужую организацию, получает
403 cross_org. Молчаливого отката «к своей организации» не происходит никогда — это осознанное решение, чтобы ошибка в скрипте не выглядела как успех.
Список шаблонов камер всегда сужен ровно до одной организации: суперадминистратор
без ?org= увидит шаблоны своей организации, а не всей установки. Чтобы посмотреть чужие,
указывайте ?org= явно.
Формат ошибок#
Тело ошибки всегда одинаковой формы — объект с одним полем error, в котором лежит машинный код,
пригодный для сравнения в коде:
{ "error": "camera_limit" }
Некоторые ошибки добавляют поля с подробностями (например, detail и retry_after_seconds у отказов
со стороны камеры) — они описаны там, где встречаются. Человекочитаемых текстов на русском API не
возвращает: сообщение для оператора формирует клиент.
| Код | Что означает в этом API | Типичные значения error |
|---|---|---|
400 |
запрос синтаксически разобран, но не прошёл проверку значений | name_empty, bad_camera_status, bad_ttl, bad_range |
401 |
не аутентифицирован: токена нет, он негоден, просрочен, сессия отозвана, пользователь удалён или отключён | unauthorized, evicted, bad_credentials |
403 |
аутентифицирован, но не та роль или не та организация | forbidden, not_admin, cross_org, no_dvr, no_export |
404 |
объект не существует или не виден вызывающему | not_found |
409 |
конфликт состояния: лимит, дубликат, недопустимый переход | already_exists, camera_limit, organization_deleting, camera_changed_retry |
422 |
JSON корректен, но не соответствует схеме: не тот тип поля, нет обязательного поля, неизвестное значение перечисления | тело формирует HTTP-слой, поля error в нём может не быть |
429 |
слишком часто: блокировка входа по IP либо исчерпаны слоты потоковых подписок | throttled, live_stream_capacity |
500 |
внутренняя ошибка, которой быть не должно | url_build_failed |
501 |
такого маршрута нет — ответ на опечатку в пути | not_implemented |
502 |
отказ вышестоящей стороны: камера по ONVIF или нода | onvif_upstream, node_bad_reply |
503 |
временная деградация: недоступна база, нода или журнал аудита | db_unavailable, node_unavailable, audit_unavailable |
Как это читать на практике:
404— это «не найдено или не ваше». На маршрутах доступа к видео камера чужой организации отвечает404 not_found, а не403: Plane не раскрывает даже факт существования чужого объекта. На административных маршрутах чужая организация даёт403 cross_org, потому что там объект уже опознан по идентификатору.409 camera_changed_retry— не ошибка, а гонка. Строку изменили параллельно, повторите запрос как есть.501, а не404, на неизвестный путь. Если вы получили{"error":"not_implemented"}— ищите опечатку в URL, а не отсутствующий объект.503— повторяйте с нарастающей паузой. Plane при отказе базы деградирует, но не падает; через несколько секунд запрос обычно проходит.- Нарушение уникальности (одинаковое имя организации, тега, шаблона, логина) всегда приходит как
409 already_exists. - Не всякая ошибка приходит в конверте
{"error": …}. Запрос, который не удалось даже разобрать, отклоняет HTTP-слой до обработчика: битый JSON или неверный тип поля в теле дают400/422, а негодная строка запроса —400с простым текстомFailed to deserialize query string …. Клиент должен переживать ответ, не являющийся JSON.
Постраничная выдача#
Списки не используют OFFSET и не считают общее количество строк — на установках с десятками тысяч
камер это дало бы линейную деградацию. Вместо этого применяется keyset-пагинация: клиент
передаёт позицию последней увиденной строки.
Форматов ответа три; какой у конкретного маршрута — указано в его описании.
Конверт {rows, next} — административные списки#
Так отвечают /api/v1/orgs, /api/v1/users, /api/v1/tags, /api/v1/cameras,
/api/v1/camera-templates.
{ "rows": [ { "id": "<UUID>", "…": "…" } ], "next": "<UUID>" }
| Параметр | Тип | По умолчанию | Смысл |
|---|---|---|---|
limit |
целое | 500 |
сколько строк вернуть; значение прижимается к диапазону [1, 500] |
after |
uuid | — | вернуть строки со id > after; сюда подставляется next предыдущей страницы |
Правила, которые стоит запомнить:
- Сортировка — по возрастанию
id. Идентификаторы монотонны по времени создания, поэтому это практически порядок добавления. next— идентификатор последней строки страницы. Он заполняется только если страница вернулась ровно полной. Когдаnextравенnull, страниц больше нет.next≠ «дальше точно что-то есть». Если общее число строк кратноlimit, последняя страница придёт полной,nextбудет непустым, а следующий запрос вернёт{"rows":[],"next":null}. Это нормально: цикл завершается поnext == null, а не по пустому ответу.limit=0, отрицательный или отсутствующийlimitдают полную страницу в 500 строк, а не пустой ответ и не маленькое значение по умолчанию.
Обход всех страниц целиком:
after="" while :; do page=$(curl -sS "$BASE/api/v1/cameras?limit=500${after:+&after=$after}" \ -H "Authorization: Bearer $TOKEN") # полезная работа со страницей: здесь — id и имя каждой камеры echo "$page" | python3 -c ' import sys, json for r in json.load(sys.stdin)["rows"]: print(r["id"], r["name"], sep="\t")' after=$(echo "$page" | python3 -c 'import sys,json;print(json.load(sys.stdin)["next"] or "")') [ -n "$after" ] || break # next == null → страниц больше нет done
Конверт {…, next_cursor} — непрозрачный курсор#
Так отвечают маршруты, где сортировка идёт не по id, а по имени, или где выдача собирается с
нескольких нод: /api/v1/sessions, /api/v1/mobile/cameras.
{ "rows": [ … ], "next_cursor": "<непрозрачная строка>" }
- Массив строк называется
rowsу/api/v1/mobile/camerasиitemsу/api/v1/sessions— последний отдаёт ещёlimit,partial_nodesиsampled_at(unix-секунды). - Курсор передаётся обратно как
?cursor=…. Его не нужно разбирать — формат внутренний и может измениться. limitздесь другой: по умолчанию50, прижимается к[1, 100].- У
/api/v1/mobile/camerasnext_cursor— надёжный признак конца: он появляется, только если следующая строка действительно существует. Испорченный курсор там даёт400 bad_cursor. - У
/api/v1/sessionsстраница может прийти корочеlimitи всё равно иметьnext_cursor— выдача собирается опросом нод (не более 4 нод за запрос, по 2 секунды на каждую). Испорченный курсор там, наоборот, молча игнорируется и выдача начинается сначала. Непустойpartial_nodesв ответе означает «страница неполная», а не «ошибка».
Без пагинации#
Часть маршрутов возвращает заведомо ограниченный набор и курсора не имеет вовсе:
GET /api/v1/cameras/lite (до 500 строк {id, name}),
GET /api/v1/cameras/{id}/shares (действующие ссылки одной камеры).
Ответ — {"rows":[…]} или {"shares":[…]} без поля next.
Потоки событий (SSE)#
Живое состояние отдаётся потоками Server-Sent Events — обычный HTTP-ответ
Content-Type: text/event-stream, который не закрывается. Так работает, например,
GET /api/v1/cameras/runtime/stream (статус камер в реальном времени).
Чтение из командной строки:
curl -N -sS "$BASE/api/v1/cameras/runtime/stream?ids=<UUID>,<UUID>" \ -H "Authorization: Bearer $TOKEN" \ -H 'Accept: text/event-stream'
Ключ -N отключает буферизацию curl; без него кадры будут копиться и приходить пачками.
Как выглядит поток:
: keep-alive
id: 1
data: {"seq":1,"sampled_at":"2026-08-18T10:00:00Z","rows":{"<UUID>":{"alive":true,"health":"ok"}},"profiles":{}}
id: 2
data: {"seq":2,"sampled_at":"2026-08-18T10:00:01Z","rows":{"<UUID>":{"alive":true,"health":"ok"}},"profiles":{}}
Правила разбора — одинаковые для всех потоков этого API:
- Имени события нет. Кадры приходят как события по умолчанию, поэтому в браузере их принимает
source.onmessage, а не подписка на именованное событие. id:— порядковый номер кадра (seqпродублирован и внутри JSON).- Строка, начинающаяся с
:— комментарий поддержания соединения; он приходит каждые 15 секунд и его нужно молча пропускать. Отсутствие таких строк дольше ~30 секунд — признак, что соединение порвал промежуточный прокси. - Каждый кадр — полное состояние на свой момент времени, а не приращение. Пропущенный или устаревший кадр не нужно догонять: следующий приведёт картину в порядок. При переподключении докачивать историю тоже не нужно.
- Поток конечен. Он завершится не позже, чем истечёт актуальность авторизации вызывающего (не более 5 минут). Долгоживущая страница обязана переподключаться — считайте штатное закрытие потока нормой, а не ошибкой.
- Слот подписки удерживается всё время, пока открыто тело ответа. Клиент, который открыл поток и не читает его, продолжает занимать слот. Закрывайте соединение явно.
- При исчерпании слотов приходит
429с телом{"error":"live_stream_capacity","scope":"global"|"principal","retry_after":N}и заголовкомRetry-After. Дождитесь указанного времени, а не повторяйте сразу.
Если перед Plane стоит прокси, для маршрутов */stream отключите буферизацию ответа и увеличьте
таймаут чтения. Иначе поток внешне «работает», но кадры приходят слипшимися пачками или соединение
рвётся по таймауту через минуту.
Частые ошибки интеграции#
Ниже — то, на чём чаще всего теряют время при первой интеграции. Каждый пункт — реальный случай.
Пустое значение параметра — это не «параметр не задан»#
Правило: параметр либо передаётся с корректным значением, либо не передаётся совсем. Пустая строка в строке запроса — всегда ошибка. Пустое значение не равносильно отсутствию параметра ни для одного фильтра.
Отказ выглядит по-разному в зависимости от типа параметра:
# Строковый фильтр со списком допустимых значений: пустая строка в список не входит. curl -sS "$BASE/api/v1/cameras?status=" -H "Authorization: Bearer $TOKEN" # 400 {"error":"bad_camera_status"} # Нестроковый параметр (число, uuid, bool): разбор строки запроса падает ДО обработчика, # и ответ приходит обычным текстом, а не в конверте {"error": …}. curl -sS "$BASE/api/v1/cameras?limit=" -H "Authorization: Bearer $TOKEN" # 400 Failed to deserialize query string … # ПРАВИЛЬНО: фильтр не нужен — параметра не должно быть в строке запроса вовсе. curl -sS "$BASE/api/v1/cameras" -H "Authorization: Bearer $TOKEN"
Так же ведут себя ?org=, ?after=, ?tag=, ?node=, ?disabled= и любые другие параметры,
тип которых не строка: пустое значение даёт текстовую ошибку 400 Failed to deserialize query string. Обработчик тела ошибки, рассчитанный на JSON, на такой ответ споткнётся — предусмотрите
это в клиенте.
Собирайте строку запроса из непустых пар, а не подставляйте переменные в готовый шаблон:
q=""; status="live"; limit=100 # часть фильтров пустая args="" [ -n "$q" ] && args="$args&q=$q" [ -n "$status" ] && args="$args&status=$status" [ -n "$limit" ] && args="$args&limit=$limit" curl -sS "$BASE/api/v1/cameras?${args#&}" -H "Authorization: Bearer $TOKEN"
{"rows":[],"next":null} — нормальный ответ на корректный фильтр, под который ничего не
подошло: например, ?status=parked, когда припаркованных камер нет. Пустой результат и отказ
разбора различаются кодом ответа: 200 против 400.
Всегда задавайте limit явно#
Отсутствующий, нулевой или отрицательный limit в конверте {rows, next} не даёт «разумное
значение по умолчанию» — он даёт полную страницу в 500 записей. Запрос ?limit=0, которым
обычно хотят «просто проверить доступность», выкачает 500 камер со всеми полями. Указывайте
limit в каждом запросе списка.
Токен ссылки общего доступа выдаётся ровно один раз#
При создании ссылки (POST /api/v1/cameras/{id}/shares) токен возвращается только в ответе на
создание. В базе хранится лишь идентификатор ссылки — он нужен для отзыва; сам токен прочитать
повторно нельзя ни из списка ссылок, ни откуда-либо ещё. Потеряли — выпускайте новую ссылку и
отзывайте старую.
Права ссылки — это пересечение двух ограничений: полномочий того, кто её выпускает, и
физических возможностей самой камеры. Поэтому на камеру без записи невозможно выдать ссылку с
доступом к архиву или экспорту, а на камеру без ONVIF — с PTZ, и суперадминистратор здесь не
исключение. Запрошенные «лишние» биты не отбрасываются молча — запрос отклоняется с
403 bits_exceed_grant.
Интеграции нужна собственная учётная запись#
Организация может ограничивать число одновременных сессий одного пользователя
(max_sessions_per_user). Когда лимит задан, новый вход вытесняет самые старые сессии этого же
пользователя — побеждает свежий. Если интеграция логинится под тем же admin, что и живой
человек, то каждый её запуск по расписанию будет молча выкидывать администратора из браузера
(и наоборот — вход администратора будет ломать интеграцию, которая начнёт получать
401 evicted).
Заведите для каждой интеграции отдельного пользователя с говорящим именем (integration-1c,
backup-export, monitoring), выдайте ему минимально необходимую роль или гранты и держите
пароль в хранилище секретов. Тогда:
- вытеснение сессий никого не задевает;
- в журнале аудита видно, кто именно совершил действие;
- отключение интеграции — это отключение одной учётной записи, а не смена общего пароля.
Дополнительно: долгоживущему процессу вместо повторных входов следует продлевать сессию через
POST /api/v1/session/refresh — тогда сессия остаётся одна и вытеснять нечего.
Смена пароля рвёт все сессии этого пользователя#
POST /api/v1/users/{id}/password отзывает все живые сессии владельца пароля, включая ту, из
которой вы сделали запрос. Скрипт, который меняет свой же пароль и продолжает работу со старым
токеном, получит 401 на следующем запросе. Выполните вход заново сразу после смены.
Символы % и _ в поиске работают как шаблон#
В поиске ?q= по административным спискам пользователей и камер эти символы не экранируются и
ведут себя как подстановочные знаки SQL: ?q=% найдёт всё, ?q=a_c найдёт abc. Если вы
подставляете в q текст, введённый пользователем, либо экранируйте эти символы на своей стороне,
либо учитывайте эффект. В мобильной витрине и в GET /api/v1/cameras/lite они, наоборот,
трактуются буквально.
Пропущенное поле и null — разные вещи#
В запросах PATCH пропуск поля означает «не трогать», а явный null — «очистить значение».
Но очистить через null можно не любое поле: у части полей null равносилен пропуску. Какие
именно поля очищаются, указано в описании каждого маршрута — например, для камер это перечислено в
разделе Камеры.
И отдельно: null в числовых лимитах организации означает «без ограничения», а не «ноль».
Ноль — это честный ноль, то есть «нельзя ничего».
Не запрашивайте секреты на каждой отрисовке#
GET /api/v1/cameras/{id}/credentials — единственный маршрут, отдающий пароль камеры, и каждый
его вызов пишется в журнал аудита. Это действие «по нажатию кнопки». Интеграция, которая тянет
пароли пачкой при каждом обновлении экрана, за сутки создаёт тысячи записей аудита и делает журнал
бесполезным.
Проверяйте partial_nodes и next, а не длину массива#
Пустой массив в ответе не всегда означает «данных нет»: у списка сессий это может быть страница,
собранная с ноды, которая не ответила (см. partial_nodes), а у конверта {rows, next} — обычная
последняя страница цикла. Ориентируйтесь на признаки конца, описанные выше, а не на количество
строк.
Не полагайтесь на поля-подсказки#
Флаги вроде ptz, events, live в дескрипторах просмотра — это подсказки для интерфейса
(«показывать ли кнопку»). Реальную проверку прав делает каждый маршрут самостоятельно, и нода
проверяет их ещё раз. Интеграция, которая по подсказке решила, что действие разрешено, всё равно
получит 403 — обрабатывайте его как штатный ответ.