Сервисы и identity
Как то, что вы деплоите, получает доступ к секретам: сервис, identity и два режима её выдачи — постоянный client secret и одноразовый enrollment-токен.
Сервис
Сервис — это то, что вы деплоите: воркер, API, cron-задача. Контейнер, к которому привязываются учётные данные. Сам по себе сервис ничего не читает — доступ даёт identity.
Завести сервис можно в кабинете (раздел «Сервисы» проекта) или запросом:
curl -s $BASE/projects/$PROJECT/services \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"Billing worker","slug":"billing-worker"}'
Slug — строчные буквы, цифры и дефис (billing-worker), уникален в проекте;
совпадение отклоняется с 409 CONFLICT. Право — роль admin в проекте.
Удаление сервиса (DELETE /services/:id) каскадно удаляет все его identity.
Переименовать сервис — PATCH /services/:id с одним полем name (в кабинете
— «переименовать» в карточке сервиса). Право то же, что у остальной правки
сервисов: admin проекта или admin организации.
Слаг не меняется. Это адрес, по которому сервис зовут API и CLI, и он
уникален в проекте: сменить его значило бы сломать всё, что на него
ссылается. Присланный в PATCH слаг не игнорируется молча, а отклоняется с
VALIDATION — молча проигнорированное поле выглядит как удавшаяся правка,
после которой сервис ищут по новому адресу и не находят. Нужен другой слаг —
заводите другой сервис.
Identity
Машинная identity (дальше — просто identity) — учётные данные одного сервиса
в одном окружении. Если сервис работает в staging и в production, у него
две identity. Скоуп identity — проект × окружение × необязательный набор
ключей: прочитать другое окружение или чужой проект она не может независимо
от того, откуда пришёл запрос.
Создание (в кабинете — из карточки сервиса):
curl -s $BASE/services/$SERVICE/identities \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name": "prod worker",
"environmentId": "'$ENV'",
"mode": "universal",
"allowedKeys": ["DB_*", "REDIS_URL"],
"allowedCidrs": ["203.0.113.0/24"],
"accessTokenTtlSeconds": 900
}'
Право — именно проектная роль admin: роли владельца или администратора
организации здесь недостаточно. Это осознанно: identity читает значения,
включая restricted, который для людей закрыт всегда, — поэтому выдаёт её
тот, у кого есть доступ к данным проекта, а не только к его настройкам.
Владельцу организации это не запрет, а лишний шаг: он выдаёт себе проектную
роль одной командой, и этот шаг остаётся в журнале.
Параметры:
| Параметр | По умолчанию | Что значит |
|---|---|---|
mode | — | universal или enrollment — см. ниже |
accessTokenTtlSeconds | 900 | TTL access-токена в секундах, от 60 до 86400 |
allowedKeys | все ключи | точные имена или префиксы со звёздочкой |
allowedCidrs | любой адрес | CIDR-список, откуда разрешён доступ |
allowedKeys: пустой список — это «всё», а не «ничего»
Формат элемента — точное имя ключа (DB_PASSWORD) или префикс со
звёздочкой на конце (DB_*). Других масок нет: паттерн — это либо валидное
имя ключа, либо валидный префикс плюс *. Опечатка отклоняется при создании
identity ошибкой VALIDATION, а не молча запирает сервис на первом же
запросе.
Главная ловушка — семантика пустого списка: если allowedKeys не задан или
пуст, identity читает все ключи окружения. Ограничение начинается с
первого элемента списка. «Сервису ничего не нужно» так выразить нельзя —
просто не создавайте identity, пока она не нужна.
Фильтр применяется после разрешения ссылок общих секретов: ключ, приехавший по ссылке из общего проекта, проверяется по имени так же, как собственный. Обойти ограничение ссылкой нельзя.
allowedCidrs
Список CIDR; пустой — любой адрес. Проверяется при выдаче токена (логин и
обмен enrollment-токена) и при каждом чтении записей. Если адрес клиента
определить нельзя, а список задан, доступ отклоняется: неизвестный IP не
считается доверенным. Отказ при логине пишется в журнал событием
identity.login-denied, при обмене — enrollment.denied.
Режим universal: постоянный client secret
Ответ на создание содержит clientSecret:
{
"identity": { "id": "...", "clientId": "mi_..." },
"clientSecret": "..."
}
Client secret показывается ровно один раз; в базе остаётся только его SHA-256. Потерянный секрет не восстанавливают — выпускают новую identity.
Дальше сервис логинится этой парой:
curl -s $BASE/machine/login -H 'content-type: application/json' \
-d '{"clientId":"mi_...","clientSecret":"..."}'
В ответ — access-токен с TTL из accessTokenTtlSeconds identity и скоупом
«проект × окружение». На практике этот запрос за вас делает
SDK: он передаёт ещё и publicKey — эфемерную x25519-пару,
сгенерированную на каждый процесс, — а сервер обновляет сохранённый ключ
при каждом входе.
Этот режим отвечает на вопрос «как войти, когда client secret уже на машине». Но сначала его туда надо доставить — и где бы он ни побывал по дороге (образ, репозиторий, лог CI), он там остаётся навсегда. Для случая, когда защищённого канала доставки нет, есть второй режим.
Режим enrollment: одноразовый enrollment-токен
При mode: "enrollment" ответ на создание другой:
{
"identity": { "id": "...", "clientId": "mi_..." },
"enrollmentToken": "...",
"enrollmentExpiresAt": "2026-08-28T12:10:00.000Z"
}
Enrollment-токен — одноразовый токен первичной выдачи: сервис при первом
старте меняет его на постоянные client ID и client secret. Токен живёт
10 минут (точный срок — в enrollmentExpiresAt), показывается один
раз, в базе хранится только его хэш.
Цена утечки у двух режимов разная. Постоянный client secret, попавший в лог CI, работает до отзыва — и о том, что им пользуются, можно не узнать никогда. Enrollment-токен после обмена бесполезен, поэтому его утечка постфактум ничего не стоит. А вот если токен перехватили до обмена и использовали первыми — ваша попытка обмена станет второй, и это уже не просто отказ, а тревога (см. ниже).
Обмен
POST /machine/enroll
{ "enrollmentToken": "...", "publicKey": "..." }
publicKey обязателен — свежий x25519-ключ в base64, под него сервер
обернёт ключ данных при первом чтении. В ответ приходят сразу и постоянные
учётные данные, и первый токен:
{
"clientId": "mi_...",
"clientSecret": "...",
"accessToken": "...",
"expiresAt": "2026-08-28T12:15:00.000Z"
}
После обмена identity получает режим universal: дальше она логинится
обычной парой client ID + client secret через /machine/login, а SDK при
первом входе заменит публичный ключ своим.
В SDK обмен есть. Обычный путь — EnvMasterClient.connect(): он найдёт
учётные данные в файле, а если их там ещё нет, обменяет токен и сохранит
результат.
const client = await EnvMasterClient.connect({
baseUrl: process.env.ENVMASTER_URL!,
project: "billing",
environment: "prod",
// Путь указывается явно, умолчания нет: секрет машины должен лежать там,
// где его положили вы.
credentialsFile: "/var/lib/myapp/envmaster.json",
enrollmentToken: process.env.ENVMASTER_ENROLLMENT_TOKEN,
});
Порядок внутри: готовые учётные данные → файл → обмен. Обмен всегда последний, потому что он необратим. Перед ним SDK проверяет, что в файл удастся записать, — иначе получилось бы, что токен потрачен, а ответ класть некуда, и сервис заперт навсегда.
По этой же причине обмен без credentialsFile запрещён и требует
allowEnrollWithoutFile: true: учётные данные, никуда не сохранённые,
исчезнут вместе с процессом. Отдельные шаги доступны как enroll(),
readCredentialsFile(), writeCredentialsFile() — если сохранять вы
предпочитаете сами.
В CLI обмена по-прежнему нет. Ниже — прямой запрос к API, если делать обмен не из приложения (ключ нужен один раз, для самого обмена, поэтому его генерирует openssl):
PUB=$(openssl genpkey -algorithm X25519 | \
openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64)
curl -s $BASE/machine/enroll -H 'content-type: application/json' \
-d "{\"enrollmentToken\":\"$ENROLLMENT_TOKEN\",\"publicKey\":\"$PUB\"}"
Полученные clientId и clientSecret процесс обязан сохранить: токен
одноразовый, и без сохранения следующий запуск окажется заперт. Дальше
identity работает как обычная universal. Обмен ограничен по частоте: 5 попыток за 15 минут — скрипт с
повторами по таймеру упрётся в 429 RATE_LIMITED.
Ошибки обмена
| Код | Когда | Что это значит |
|---|---|---|
401 UNAUTHENTICATED | токен неизвестен | опечатка или токен не из этой системы |
403 ENROLLMENT_EXPIRED | 10 минут прошли, токен не использован | рутина: выпустите новую identity — тревоги тут нет |
403 ENROLLMENT_USED | токен уже использован | тревога, см. ниже |
Просроченный, но неиспользованный токен получает отдельную ошибку именно чтобы не путать протухший деплой с перехватом.
Повторное использование — это тревога
Захват токена атомарный (compare-and-set по usedAt), поэтому две
конкурирующие попытки не могут выиграть обе: постоянные учётные данные
получает ровно одна. Проигравшая получает 403 ENROLLMENT_USED, а в журнал
пишется событие enrollment.reuse-detected и уходит
вебхук — это одно из двух событий, которые стоит
подключить в любом случае.
Вторая попытка обмена означает, что токен видел кто-то ещё: успешный обмен был не ваш, либо токен утек из лога и им воспользовались. В обоих случаях постоянные учётные данные, выданные при первом обмене, надо считать скомпрометированными:
- Отзовите identity —
DELETE /services/:id/identities/:identityId(в кабинете — кнопка «Отозвать»). Отзыв мгновенный: уже выданные access-токены перестают приниматься со следующего запроса. - Выпустите новую identity и повторите выдачу.
Сервер identity сам не отзывает: автоматический отзыв по второму предъявлению превращал бы любую утечку enrollment-токена в готовый способ убить работающий сервис — достаточно предъявить перехваченный токен ещё раз. Решение остаётся за человеком, который видит тревогу.
Переименование identity
PATCH /services/:id/identities/:identityId с одним полем name (в кабинете
— «переименовать» в строке identity). client_id при этом не меняется:
по нему identity аутентифицируется, и смена сломала бы работающий сервис.
Право — роль admin именно в проекте, то же, что при выдаче. Роль в
организации сама по себе не подходит: имя identity подписывает её события в
журнале и в вебхуках, и кто может назвать чужую машину, тот может выдать её за
другую в разборе инцидента.
Переименование пишется в журнал событием identity.renamed вместе со старым
именем — без него разбор события годичной давности врал бы, называя identity
именем, которого тогда не было. Отозванную identity переименовать можно: по-
человечески назвать «deploy 12345» нужно как раз тогда, когда разбираются
задним числом.
Отзыв и сроки
- Отзыв (
revokedAt) действует мгновенно и неотменим; повторный отзыв —409 CONFLICT. Проверка делается на каждый запрос, поэтому TTL access-токена не является окном уязвимости. - Каждый вход identity пишется в журнал (
identity.login, с флагомnewIp, если адрес новый), каждое чтение — тоже (entries.fetch, с фактически выданным набором ключей). - Identity без клиентов не умирает сама: если сервис выведен из эксплуатации, отзовите её — неактивность доступ не закрывает.