env-master
ENСоздать аккаунт
Разделы документации

Сервисы и 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 — см. ниже
accessTokenTtlSeconds900TTL 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_EXPIRED10 минут прошли, токен не использованрутина: выпустите новую identity — тревоги тут нет
403 ENROLLMENT_USEDтокен уже использовантревога, см. ниже

Просроченный, но неиспользованный токен получает отдельную ошибку именно чтобы не путать протухший деплой с перехватом.

Повторное использование — это тревога

Захват токена атомарный (compare-and-set по usedAt), поэтому две конкурирующие попытки не могут выиграть обе: постоянные учётные данные получает ровно одна. Проигравшая получает 403 ENROLLMENT_USED, а в журнал пишется событие enrollment.reuse-detected и уходит вебхук — это одно из двух событий, которые стоит подключить в любом случае.

Вторая попытка обмена означает, что токен видел кто-то ещё: успешный обмен был не ваш, либо токен утек из лога и им воспользовались. В обоих случаях постоянные учётные данные, выданные при первом обмене, надо считать скомпрометированными:

  1. Отзовите identity — DELETE /services/:id/identities/:identityId (в кабинете — кнопка «Отозвать»). Отзыв мгновенный: уже выданные access-токены перестают приниматься со следующего запроса.
  2. Выпустите новую 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 без клиентов не умирает сама: если сервис выведен из эксплуатации, отзовите её — неактивность доступ не закрывает.