Безопасность и multi-tenancy в NATS: accounts, JWT, nkeys и TLS

Изоляция через accounts (глубже, чем vhost), exports/imports, три модели аутентификации (user/pass, nkeys, децентрализованный JWT через nsc и resolver) и TLS/mTLS — то, чего нет у соседей в таком виде

Это восьмая статья серии «Погружение в NATS». Первая статья познакомила с subjects и permissions на уровне одного пользователя; четвёртая показала leaf nodes — edge-площадки, которые инициируют одно соединение к hub-кластеру. Всё это время в конфигурации молчаливо жил один account — $G (global), созданный по умолчанию. Пора обсудить, что происходит, когда accounts становится несколько, и почему это не косметическая надстройка, а отдельный, самый глубокий уровень изоляции, который есть у NATS.

Безопасность и multi-tenancy в NATS: accounts, JWT, nkeys и TLS

Если RabbitMQ и Kafka уже встречались в вашей практике, у vhost и ACL RabbitMQ есть прямой родственник в NATS — account, только глубже. А вот у decentralized JWT и nkeys прямого аналога нет вообще ни у одного из соседей: это модель аутентификации, спроектированная с нуля под мульти-тенантность и edge, где сервер не обязан заранее знать всех своих клиентов. Разберём по порядку: accounts как единица изоляции, exports/imports как контролируемый мост между ними, три модели аутентификации и когда какая нужна, TLS/mTLS и permissions на уровне subject.

В статье

Accounts как изоляция

Account — единица полной изоляции в NATS: у каждого account свой subject-namespace, и по умолчанию два account не видят друг друга вообще, даже если публикуют в subject с одинаковым именем. Публикация orders.eu.new в account TENANT_A и подписка на orders.eu.new в account TENANT_B — это два разных, никак не связанных события. Сервер физически не пересылает трафик между accounts, если это не разрешено явно.

Задаётся в конфиге сервера блоком accounts{}:

accounts: {
  TENANT_A: {
    users: [
      {user: alice, password: alice_pw}
    ]
  },
  TENANT_B: {
    users: [
      {user: bob, password: bob_pw}
    ]
  },
}

Каждый пользователь принадлежит ровно одному account. Один и тот же сервер (один процесс, один порт 4222) обслуживает произвольное число accounts одновременно — это и есть встроенная мульти-тенантность: не «поднять N серверов», а «поднять N изолированных namespace на одном сервере».

Изоляция не ограничивается Core subjects. JetStream тоже уважает границы account: у каждого account — свой набор streams, свои лимиты (max_streams, max_consumers, max_mem, max_file), которые можно задать прямо в блоке account:

accounts {
  TENANT_A: {
    jetstream {
      max_mem: 512M
      max_file: 1G
      max_streams: 10
      max_consumers: 100
    }
  }
}

Stream ORDERS, созданный в TENANT_A, не виден и не адресуем из TENANT_B — ни по имени, ни через API. Сумма лимитов всех accounts должна помещаться в общие лимиты сервера (jetstream{max_mem, max_file} на верхнем уровне); превышение — ошибка конфигурации, а не тихое переполнение.

graph TD subgraph Server["nats-server (один процесс, порт 4222)"] subgraph A["Account TENANT_A"] UA["alice"] --> SA["subjects: orders.>"] SA --> JSA["JetStream: stream ORDERS
(изолирован)"] end subgraph B["Account TENANT_B"] UB["bob"] --> SB["subjects: orders.>"] SB --> JSB["JetStream: stream ORDERS
(другой, изолирован)"] end end A -.->|"без exports/imports —
ноль пересечения"| B style A fill:#f9f3e3,stroke:#8b7355 style B fill:#c9e4c5,stroke:#5b8a5e

graph TD
  subgraph Server["nats-server (один процесс, порт 4222)"]
    subgraph A["Account TENANT_A"]
      UA["alice"] --> SA["subjects: orders.>"]
      SA --> JSA["JetStream: stream ORDERS
(изолирован)"] end subgraph B["Account TENANT_B"] UB["bob"] --> SB["subjects: orders.>"] SB --> JSB["JetStream: stream ORDERS
(другой, изолирован)"] end end A -.->|"без exports/imports —
ноль пересечения"| B style A fill:#f9f3e3,stroke:#8b7355 style B fill:#c9e4c5,stroke:#5b8a5e
Два account на одном сервере: полностью изолированные subject-namespace и JetStream-домены

Мост от RabbitMQ. Ближайший знакомый концепт — vhost: логическая группа exchange/queue/binding с собственным пространством имён и правами доступа на одном брокере. Но глубина изоляции разная. В RabbitMQ vhost разделяет топологию маршрутизации (exchange, queue, binding) — а вот пользователи, плагины и часть настроек кластера остаются общими на уровне брокера, и permissions на vhost настраиваются отдельно поверх него как ACL. В NATS account — это изоляция не только subject-namespace, но и JetStream-домена, лимитов ресурсов и (в decentralized JWT-модели, о которой дальше) даже криптографической цепочки доверия: у каждого account может быть свой независимый набор доверенных identity, не пересекающийся с другими accounts на том же сервере. Практическое следствие: спроектировать честную мульти-тенантность на NATS через accounts проще, чем эмулировать тот же уровень изоляции через vhost + ACL в RabbitMQ — граница жёстче по умолчанию, а не за счёт дисциплины конфигурации.

Exports/Imports

Изоляция по умолчанию — это ещё не вся история: иногда двум accounts всё-таки нужно обменяться данными, не сливая namespace целиком. Для этого — exports и imports: явный, контролируемый мост между двумя конкретными accounts на уровне конкретных subjects.

Экспортирующий account объявляет, что он готов отдать наружу:

accounts: {
  TENANT_A: {
    users: [{user: alice, password: alice_pw}]
    exports: [
      {stream: "events.public.>"}
      {service: "billing.charge", accounts: [TENANT_B]}
    ]
  },
  TENANT_B: {
    users: [{user: bob, password: bob_pw}]
    imports: [
      {stream: {account: TENANT_A, subject: "events.public.>"}, prefix: "from-a"}
    ]
  },
}

Два типа экспорта, с разной семантикой:

  • stream — односторонняя трансляция: всё, что публикуется в экспортируемый subject, становится видимым импортирующему account. Публикатор не знает и не заботится, кто именно импортирует.
  • service — модель запрос/ответ (см. request/reply из первой статьи): импортирующий account отправляет запрос в subject экспорта, экспортирующий account на него отвечает. Это RPC поверх границы accounts.

accounts: [TENANT_B] в export — экспорт виден только перечисленным accounts; без этого поля экспорт публичный, и импортировать его может любой account, знающий subject и public key экспортирующего account. Импорт всегда требует существующего соответствующего export на другой стороне — самоимпорт (account импортирует сам у себя) невозможен. prefix (для stream) и to (для service) переименовывают subject локально на стороне импортирующего account — это развязывает конвенции именования: TENANT_B видит чужой поток под своим собственным префиксом, не завися от того, как назвал subject TENANT_A.

sequenceDiagram participant Alice as alice (TENANT_A) participant SA as Account TENANT_A participant SB as Account TENANT_B participant Bob as bob (TENANT_B) Note over SA: export: events.public.> Note over SB: import: events.public.> → from-a.> Bob->>SB: SUB from-a.events.new Alice->>SA: PUB events.public.new "order" SA->>SB: доставка через явный import SB->>Bob: MSG from-a.events.new "order"

sequenceDiagram
  participant Alice as alice (TENANT_A)
  participant SA as Account TENANT_A
  participant SB as Account TENANT_B
  participant Bob as bob (TENANT_B)

  Note over SA: export: events.public.>
  Note over SB: import: events.public.> → from-a.>

  Bob->>SB: SUB from-a.events.new
  Alice->>SA: PUB events.public.new "order"
  SA->>SB: доставка через явный import
  SB->>Bob: MSG from-a.events.new "order"
Exports/imports: контролируемый мост между двумя изолированными accounts

Мост от RabbitMQ. Прямого аналога у exports/imports в терминах RabbitMQ нет: там межvhost-обмен обычно решается через shovel/federation (статья про Federation и Shovel) — это отдельный, более тяжеловесный механизм, рассчитанный в первую очередь на связь между разными брокерами или кластерами, а не на internal-обмен между vhost одного брокера. В NATS exports/imports — встроенный, «дешёвый» примитив ровно для этой internal-задачи: два tenant на одном сервере делятся конкретным потоком без выноса за пределы процесса.

Аутентификация: три модели

NATS поддерживает три принципиально разные модели аутентификации клиентов, и они не конкурируют, а закрывают разные сценарии.

Статическая: user/password, token

Простейшая модель — учётные данные прямо в конфиге сервера, как в примерах выше:

authorization: {
  users: [
    {user: alice, password: alice_pw}
    {token: "s3cr3t-static-token"}
  ]
}

Плюс: ничего не нужно устанавливать дополнительно, конфиг читается и работает. Минус: добавление или отзыв пользователя требует правки конфига и релоада (или рестарта) сервера на каждой ноде; пароль хранится в конфиге в открытом виде или bcrypt-хешем (сервер поддерживает $2a$-хеши, чтобы не держать пароль в чистом тексте). Годится для небольших, редко меняющихся наборов клиентов и для локальной разработки.

nkeys: ed25519 без передачи секрета

nkey — публичный ключ на базе ed25519, зарегистрированный в конфиге сервера вместо пароля:

authorization: {
  users: [
    {nkey: UDXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4}
  ]
}

Аутентификация — не передача секрета, а challenge-response: сервер присылает клиенту nonce, клиент подписывает его своим приватным ключом (seed, никогда не покидает клиента) и отправляет подпись обратно. Сервер проверяет подпись публичным ключом из конфига. Пароль по сети не передаётся вообще — ни в открытом, ни в хешированном виде. Ключевая пара генерируется утилитой nk (или nsc, о котором — дальше):

nk -gen user -pubout
# приватный seed (начинается с S) — хранится у клиента
# публичный ключ (начинается с U) — идёт в конфиг сервера

Практическая ниша nkeys — service-to-service аутентификация, где identity сервисов стабильны (не десятки тысяч короткоживущих клиентов), а полная инфраструктура operator/account JWT избыточна: конфиг сервера по-прежнему статический, просто вместо пароля — публичный ключ.

Децентрализованный JWT: operator → account → user

Это модель, спроектированная специально под accounts и мульти-тенантность, и у неё нет прямого аналога ни в RabbitMQ, ни в Kafka. Идея: вместо того чтобы хранить список accounts и users в конфиге сервера, сервер хранит только одну вещь — JWT оператора (root доверия), а всё остальное — accounts, их exports/imports, users и их permissions — представлено подписанными JWT-токенами, которые можно выпускать и распространять независимо от сервера.

Иерархия доверия трёхуровневая:

  • Operator — корень доверия для всего окружения (условно — «весь кластер компании»). Подписывает JWT accounts.
  • Account — подписывает JWT users, принадлежащих этому account. Может иметь собственный signing key, отдельный от operator.
  • User — подключается к серверу, предъявляя свой JWT и подписывая challenge приватным ключом (тот же механизм nkey-подписи, что и выше).
graph TD OP["Operator
(root доверия)"] -->|"подписывает JWT"| ACA["Account TENANT_A"] OP -->|"подписывает JWT"| ACB["Account TENANT_B"] ACA -->|"подписывает JWT"| U1["User alice"] ACA -->|"подписывает JWT"| U2["User carol"] ACB -->|"подписывает JWT"| U3["User bob"] style OP fill:#f9f3e3,stroke:#8b7355 style ACA fill:#c9e4c5,stroke:#5b8a5e style ACB fill:#c9e4c5,stroke:#5b8a5e

graph TD
  OP["Operator
(root доверия)"] -->|"подписывает JWT"| ACA["Account TENANT_A"] OP -->|"подписывает JWT"| ACB["Account TENANT_B"] ACA -->|"подписывает JWT"| U1["User alice"] ACA -->|"подписывает JWT"| U2["User carol"] ACB -->|"подписывает JWT"| U3["User bob"] style OP fill:#f9f3e3,stroke:#8b7355 style ACA fill:#c9e4c5,stroke:#5b8a5e style ACB fill:#c9e4c5,stroke:#5b8a5e
Иерархия JWT: operator подписывает account, account подписывает user — цепочка доверия проверяется на каждом уровне

Управляется всё это утилитой nsc, которая генерирует ключевые пары, подписывает JWT и хранит приватные ключи в локальном keystore (никогда не отправляя их на сервер):

nsc add operator --generate-signing-key --sys --name PROD
nsc add account --name TENANT_A
nsc add user --account TENANT_A --name alice

Каждая команда возвращает .creds-файл (JWT + nkey seed пользователя) — его передают клиентскому приложению, но не серверу. Серверу нужен только способ проверить JWT — account resolver, настраиваемый блоком resolver{}. Три типа:

  • MEMORY — JWT accounts встроены прямо в конфиг сервера (resolver_preload{}), сервер не обращается ни к диску, ни по сети. Годится для демо, CI, небольших статичных окружений; добавление account требует перегенерации конфига.
  • URL(...) — сервер запрашивает JWT по HTTP у внешнего сервиса при подключении клиента. Подходит, когда accounts управляются централизованным issuer-сервисом.
  • NATS-based resolver (type: full или type: cache) — JWT хранятся в директории на диске и синхронизируются между нодами кластера через системный account ($SYS); новые/изменённые account JWT рассылаются командой nsc push без рестарта сервера. Это рекомендуемый вариант для продакшна с часто меняющимся набором tenant.
operator: eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ...
system_account: ACZ5GFR7WCLD3MJ2M72AOOD7XQMH7RDTEYV5Y546CSV53ERRWH7N4XHA
resolver: {
  type: full
  dir: "/data/jwt"
  interval: "2m"
}
sequenceDiagram participant C as Client (creds: JWT + seed) participant S as nats-server participant R as Account Resolver C->>S: CONNECT (user JWT) S->>C: challenge (nonce) C->>S: подпись nonce приватным ключом user S->>S: проверка подписи публичным ключом из user JWT S->>R: JWT account, выпустившего user? R->>S: Account JWT (подписан operator) S->>S: проверка: account подписан доверенным operator S->>C: подключение принято, permissions/exports/imports из account JWT применены

sequenceDiagram
  participant C as Client (creds: JWT + seed)
  participant S as nats-server
  participant R as Account Resolver

  C->>S: CONNECT (user JWT)
  S->>C: challenge (nonce)
  C->>S: подпись nonce приватным ключом user
  S->>S: проверка подписи публичным ключом из user JWT
  S->>R: JWT account, выпустившего user?
  R->>S: Account JWT (подписан operator)
  S->>S: проверка: account подписан доверенным operator
  S->>C: подключение принято, permissions/exports/imports из account JWT применены
Поток аутентификации: клиент предъявляет JWT, сервер проверяет цепочку через resolver

Зачем это нужно и связка с leaf nodes. Ценность decentralized JWT раскрывается там, где нельзя (или не хочется) держать централизованный список credentials на каждом сервере: мульти-тенантные окружения, где accounts создаются и отзываются часто, и edge-топологии — leaf nodes из четвёртой статьи. Leaf node на удалённой площадке может аутентифицироваться на hub-кластере собственным JWT, выпущенным офлайн через nsc, без необходимости централизованно управлять паролями на каждой edge-точке — сервер на площадке просто предъявляет свой JWT при установлении leaf-соединения, и hub проверяет его по той же цепочке доверия operator → account.

Когда что выбрать. Статическая модель — для маленьких, стабильных окружений и разработки. nkeys — для service-to-service со стабильным набором identity, когда не нужна вся инфраструктура operator/account. Decentralized JWT — единственный вариант, если нужны accounts как полноценная единица мульти-тенантности с независимым жизненным циклом, или edge-узлы с собственной, офлайн-выпущенной identity.

TLS и mTLS

TLS в NATS — стандартный блок tls{} в конфигурации сервера:

tls {
  cert_file: "/etc/nats/tls/server-cert.pem"
  key_file: "/etc/nats/tls/server-key.pem"
  ca_file: "/etc/nats/tls/ca.pem"
  verify: true
}
  • cert_file/key_file — серверный сертификат и приватный ключ. Обязательны, если блок tls{} вообще присутствует.
  • ca_file — центр сертификации, которому доверяет сервер при проверке клиентских сертификатов. Без него используется системное хранилище доверенных CA — этого достаточно для одностороннего TLS (шифрование канала, сервер не проверяет клиента), но недостаточно для mTLS с частным/демо CA.
  • verify: true — переключает TLS в mTLS: сервер требует от клиента предъявить сертификат и проверяет его по ca_file. Без этого флага TLS шифрует канал, но никак не аутентифицирует клиента — verify превращает TLS из транспортного шифрования в ещё один механизм подтверждения identity, дополняющий (не заменяющий) user/nkey/JWT-аутентификацию.

Практическое разделение ролей: TLS/mTLS решает задачу «этому серверу можно доверять / это точно тот клиент, за которого себя выдаёт, на уровне сертификата», а accounts/JWT/permissions — задачу «что этому уже аутентифицированному клиенту разрешено делать». Для межсервисного доверия внутри инфраструктуры (например, leaf node ↔ hub, о которых шла речь выше) mTLS — стандартная практика: обе стороны предъявляют сертификаты, выпущенные общим внутренним CA, и соединение отклоняется на TLS-уровне ещё до того, как дело доходит до JWT/nkey-аутентификации.

Permissions

Внутри account permissions ограничивают, что конкретному user разрешено публиковать и на что — подписываться, на уровне subject-паттернов. Синтаксис — та же subject-грамматика с */> из первой статьи:

permissions: {
  publish: {
    allow: ["orders.eu.>"]
    deny: ["orders.eu.internal.>"]
  }
  subscribe: {
    allow: ["orders.eu.>", "_INBOX.>"]
  }
}

allow/deny — не взаимоисключающие списки, а два фильтра, применяемые вместе: subject должен попасть в allow и не попасть в deny. При пересечении deny имеет приоритет — это позволяет разрешить широкий паттерн и точечно закрыть подмножество, как в примере выше: весь orders.eu.> разрешён на публикацию, кроме orders.eu.internal.>. _INBOX.> в subscribe — типовая необходимость для request/reply (см. первую статью): без права на подписку на собственный inbox-subject клиент не сможет получить ответ на свои запросы.

В decentralized JWT-модели permissions задаются не в конфиге сервера, а прямо в JWT пользователя — nsc add user принимает те же allow/deny флаги, и правила путешествуют вместе с credentials, а не хранятся отдельно на каждой ноде сервера.

Демонстрация

Рабочий стенд — nats/05-security в digital-cookbook. Три независимых сценария, каждый в своём конфиге: статические accounts с изоляцией + одним export/import + permissions; mTLS с демо-CA и обязательной проверкой клиентского сертификата; decentralized JWT — полная цепочка nsc add operator/account/user + resolver.

git clone https://github.com/khorost-tech/digital-cookbook.git
cd digital-cookbook/nats/05-security

Сценарий (а) — статические accounts:

docker compose up -d nats-static
nats sub "app.a.public.demo" --server nats://localhost:4222 --user bob --password bob_demo_pw
# permissions violation — у bob нет прав ни на этот subject, ни на чужой account

Сценарий (б) — mTLS:

bash gen-certs.sh
docker compose up -d nats-mtls
nats pub test.mtls "no-cert" --server tls://localhost:4223 --tlsca tls/ca.pem
# tls: certificate required — verify:true требует клиентский сертификат

Сценарий (в) — decentralized JWT через nsc:

NSC="nsc" bash nsc/setup.sh
# operator DEMO → accounts APP_A/APP_B → users alice/bob,
# export/import между accounts, resolver.generated.conf (MEMORY resolver)

Полный список команд, ожидаемый вывод для каждого из трёх сценариев (включая проверку изоляции subject между accounts и один живой export/import) — в README демо. Наблюдаемость и продакшн-чеклист (Prometheus, backup, восстановление) — тема девятой, заключительной статьи серии; здесь она сознательно не рассматривается.

Вывод

Account — самая сильная граница изоляции в NATS: собственный subject-namespace, собственный JetStream-домен, собственные лимиты, и по умолчанию — ноль пересечения с другими accounts на том же сервере. Это глубже, чем vhost в RabbitMQ, где изоляция ограничена топологией маршрутизации. Exports/imports — единственный контролируемый мост через эту границу, работающий на уровне конкретных subjects, а не общего доверия между accounts.

Три модели аутентификации закрывают разные сценарии: статическая (user/password, token) — простая и годится для малых окружений; nkeys — challenge-response на ed25519 без передачи секрета по сети, для стабильных service-to-service identity; decentralized JWT — единственная модель, спроектированная под мульти-тенантность и edge, где accounts и users выпускаются офлайн через nsc и не требуют централизованного списка credentials на каждой ноде сервера. TLS/mTLS решает отдельную задачу — доверие на транспортном уровне, особенно важное для межсервисных соединений вроде leaf node ↔ hub. Permissions — тонкая настройка allow/deny поверх уже аутентифицированного и уже изолированного через account клиента.

Дальше в серии: девятая, заключительная статья — администрирование и наблюдаемость (Prometheus, backup, восстановление после сбоя).

Документация

Обсуждение в Telegram

Присоединиться →

Комментарии