Это восьмая статья серии «Погружение в NATS». Первая статья познакомила с subjects и permissions на уровне одного пользователя; четвёртая показала leaf nodes — edge-площадки, которые инициируют одно соединение к hub-кластеру. Всё это время в конфигурации молчаливо жил один account — $G (global), созданный по умолчанию. Пора обсудить, что происходит, когда accounts становится несколько, и почему это не косметическая надстройка, а отдельный, самый глубокий уровень изоляции, который есть у NATS.
Если RabbitMQ и Kafka уже встречались в вашей практике, у vhost и ACL RabbitMQ есть прямой родственник в NATS — account, только глубже. А вот у decentralized JWT и nkeys прямого аналога нет вообще ни у одного из соседей: это модель аутентификации, спроектированная с нуля под мульти-тенантность и edge, где сервер не обязан заранее знать всех своих клиентов. Разберём по порядку: accounts как единица изоляции, exports/imports как контролируемый мост между ними, три модели аутентификации и когда какая нужна, TLS/mTLS и permissions на уровне subject.
В статье
- Accounts как изоляция
- Exports/Imports
- Аутентификация: три модели
- TLS и mTLS
- Permissions
- Демонстрация
- Вывод
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} на верхнем уровне); превышение — ошибка конфигурации, а не тихое переполнение.
(изолирован)"] 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
Мост от 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"
Мост от 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-подписи, что и выше).
(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
Управляется всё это утилитой 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 применены
Зачем это нужно и связка с 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, восстановление после сбоя).
Комментарии