Skip to content

Commit 8a2001f

Browse files
infra(nats): operator-mode config + auth-failure alert + runbook
MR-P0-5 (NATS per-tenant isolation, 2026-05-20). Pairs with the api+common commits landing the queueprovider abstraction. # Files - k8s/data/nats.yaml — operator-mode nats-server config; initContainer renders /etc/nats/operator.conf from the new nats-operator k8s Secret (which the operator generates locally via `nsc` per the runbook). Recreate rollout to avoid split-brain on the in-memory resolver. - newrelic/alerts/ nats-auth-failures.json — Critical NR alert on nats_auth_failures_total > 0. Every fail = a tenant landed in legacy_open mode instead of getting real isolation. - k8s/prometheus-rules.yaml — mirror Prometheus alert (same condition, guaranteed-working in case NR Prometheus integration isn't configured). - NATS-AUTH-RUNBOOK.md — operator-only runbook: nsc generate, kubectl create secret, kubectl apply, restart sequence, verification (sys.creds connect succeeds, unauthenticated connect fails), rotation, and common failure modes. # Operator action required Generating the operator + system NKey seeds locally (nsc add operator --sys) and applying the nats-operator Secret to instant-data MUST be done by the operator — these seeds are the root of the trust chain and cannot be committed to git or generated in CI. See NATS-AUTH-RUNBOOK.md for the exact commands. Until the operator completes those steps, the api stays on the legacy_open path (fail-open). Once the Secret + nats.yaml are applied AND the api's NATS_OPERATOR_SEED is patched into instant-secrets, isolated mode kicks in on the next /queue/new. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 615164a commit 8a2001f

4 files changed

Lines changed: 380 additions & 29 deletions

File tree

NATS-AUTH-RUNBOOK.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# NATS Operator-Mode Auth Runbook
2+
3+
**Purpose:** one-time operator key generation + ongoing key rotation for the
4+
NATS per-tenant isolation cutover (MR-P0-5, 2026-05-20).
5+
6+
Pairs with: `NATS-ISOLATION-MIGRATION-2026-05-20.md` (design doc, repo root)
7+
and `infra/k8s/data/nats.yaml` (the updated manifest).
8+
9+
---
10+
11+
## TL;DR
12+
13+
```bash
14+
# 1. Install nsc / nk locally
15+
brew install nats-io/nats-tools/nsc
16+
17+
# 2. Create operator + sys account ONCE
18+
nsc add operator -n InstanodeOperator --sys
19+
nsc edit operator --sk generate
20+
21+
# 3. Extract claims + seeds
22+
OPERATOR_JWT=$(nsc describe operator --raw)
23+
OPERATOR_SEED=$(nsc list keys --all --show-seeds | awk '/Operator.*InstanodeOperator/{f=1} f && /Seed:/{print $2; exit}')
24+
# ... see "Extracting Material" below for the full set ...
25+
26+
# 4. Apply Secret (instant-data namespace)
27+
kubectl create secret generic nats-operator -n instant-data \
28+
--from-literal=OPERATOR_JWT="$OPERATOR_JWT" \
29+
--from-literal=OPERATOR_SEED="$OPERATOR_SEED" \
30+
--from-literal=SYS_ACCOUNT_PUBLIC_KEY="$SYS_ACCOUNT_PUBLIC_KEY" \
31+
--from-literal=SYS_ACCOUNT_JWT="$SYS_ACCOUNT_JWT" \
32+
--from-literal=SYS_ACCOUNT_SEED="$SYS_ACCOUNT_SEED" \
33+
--from-literal=SYS_USER_JWT="$SYS_USER_JWT" \
34+
--from-literal=SYS_USER_SEED="$SYS_USER_SEED"
35+
36+
# 5. Apply nats.yaml + restart
37+
kubectl apply -f infra/k8s/data/nats.yaml
38+
kubectl rollout restart deployment/nats -n instant-data
39+
40+
# 6. Patch instant-secrets (instant namespace) for the api + worker
41+
kubectl patch secret instant-secrets -n instant --type=merge -p '{
42+
"data": {
43+
"NATS_OPERATOR_SEED": "'$(printf '%s' "$OPERATOR_SEED" | base64)'",
44+
"NATS_SYSTEM_ACCOUNT_PUBLIC_KEY": "'$(printf '%s' "$SYS_ACCOUNT_PUBLIC_KEY" | base64)'"
45+
}
46+
}'
47+
kubectl rollout restart deployment/instant-api -n instant
48+
kubectl rollout restart deployment/instant-worker -n instant-infra
49+
```
50+
51+
---
52+
53+
## Why this is operator-only
54+
55+
Two reasons it can't be auto-applied:
56+
57+
1. **Operator + system NKey seeds never leave the operator's machine
58+
unencrypted.** The seeds are the root of the trust chain — they can sign
59+
any account, revoke any account, mint a system user that owns the
60+
cluster. Generating them in a CI job means whoever has CI access has the
61+
keys. Generate them locally, paste them into a Secret you create
62+
yourself, then delete the local copy or store it in 1Password.
63+
64+
2. **Order matters.** The pod crashes if the Secret doesn't exist. The api
65+
degrades to legacy_open if the Secret isn't patched. Either step done
66+
alone is fine; both done in the wrong order leaves the cluster either
67+
crash-looping or shipping unauthenticated traffic. Walk through it
68+
manually.
69+
70+
---
71+
72+
## Extracting Material
73+
74+
`nsc` writes everything to `~/.nsc/`. The relevant files after
75+
`nsc add operator -n InstanodeOperator --sys`:
76+
77+
```
78+
~/.nsc/
79+
├── nats/
80+
│ └── InstanodeOperator/
81+
│ ├── InstanodeOperator.jwt ← OPERATOR_JWT
82+
│ ├── SYS/
83+
│ │ ├── SYS.jwt ← SYS_ACCOUNT_JWT
84+
│ │ └── users/
85+
│ │ └── sys.jwt ← SYS_USER_JWT
86+
└── keys/
87+
├── keys/
88+
│ ├── O/ ← operator key dir
89+
│ │ └── ABCDEF.../ ← operator NKey id
90+
│ │ └── OAAAA....nk ← OPERATOR_SEED
91+
│ ├── A/ ← account key dir
92+
│ │ └── DEADBE.../ ← SYS_ACCOUNT_PUBLIC_KEY
93+
│ │ └── SAAAA....nk ← SYS_ACCOUNT_SEED
94+
│ └── U/ ← user key dir
95+
│ └── 012345.../
96+
│ └── SUAA....nk ← SYS_USER_SEED
97+
```
98+
99+
The cleanest extraction script:
100+
101+
```bash
102+
NSC_ROOT="$HOME/.nsc"
103+
OPERATOR_JWT=$(cat "$NSC_ROOT/nats/InstanodeOperator/InstanodeOperator.jwt")
104+
SYS_ACCOUNT_JWT=$(cat "$NSC_ROOT/nats/InstanodeOperator/SYS/SYS.jwt")
105+
SYS_USER_JWT=$(cat "$NSC_ROOT/nats/InstanodeOperator/SYS/users/sys.jwt")
106+
107+
# Decode account public key from JWT (the `sub` claim).
108+
SYS_ACCOUNT_PUBLIC_KEY=$(echo "$SYS_ACCOUNT_JWT" | cut -d. -f2 | base64 -d 2>/dev/null | jq -r .sub)
109+
110+
# Seeds live in ~/.nsc/keys/keys/{O,A,U}/<prefix>/<full>.nk — find them by prefix.
111+
# Operator seeds start with "SO", account seeds with "SA", user seeds with "SU".
112+
find_seed() {
113+
local prefix="$1"
114+
find "$NSC_ROOT/keys/keys" -type f -name "${prefix}*.nk" | head -1 | xargs cat
115+
}
116+
OPERATOR_SEED=$(find_seed SO)
117+
SYS_ACCOUNT_SEED=$(find_seed SA)
118+
SYS_USER_SEED=$(find_seed SU)
119+
```
120+
121+
## Verification (after the cluster restarts)
122+
123+
```bash
124+
# 1. Write a sys.creds file from SYS_USER_JWT + SYS_USER_SEED locally:
125+
cat > /tmp/sys.creds <<EOF
126+
-----BEGIN NATS USER JWT-----
127+
$SYS_USER_JWT
128+
------END NATS USER JWT------
129+
-----BEGIN USER NKEY SEED-----
130+
$SYS_USER_SEED
131+
------END USER NKEY SEED------
132+
EOF
133+
134+
# 2. Connect AS the sys user (port-forward first).
135+
kubectl port-forward -n instant-data svc/nats 4222:4222 &
136+
nats --creds /tmp/sys.creds --server nats://localhost:4222 server info
137+
# Expected: server info reply. If you get "Authorization Violation", the
138+
# operator JWT in the cluster doesn't match the operator seed that signed
139+
# this sys account — either the Secret didn't get applied, or you
140+
# regenerated the operator key and forgot to re-apply.
141+
142+
# 3. Connect UNAUTHENTICATED. This MUST fail post-cutover.
143+
nats --server nats://localhost:4222 server info
144+
# Expected: "Authorization Violation" or "Authentication Required".
145+
146+
# Clean up local creds:
147+
shred -u /tmp/sys.creds
148+
```
149+
150+
## Rotation
151+
152+
Rotating the operator seed is a cluster-restarting event (the new operator
153+
JWT has to be loaded by every nats-server process at startup). Plan:
154+
155+
1. Generate a NEW operator + sys with `nsc add operator -n InstanodeOperator-v2 --sys`.
156+
2. Re-sign every tenant account against the new operator (`nsc push -A` after `nsc env -o InstanodeOperator-v2`).
157+
3. Update the `nats-operator` Secret in one transaction:
158+
```bash
159+
kubectl create secret generic nats-operator -n instant-data \
160+
--from-literal=OPERATOR_JWT="$NEW_OPERATOR_JWT" \
161+
--from-literal=OPERATOR_SEED="$NEW_OPERATOR_SEED" \
162+
... \
163+
--dry-run=client -o yaml | kubectl apply -f -
164+
```
165+
4. `kubectl rollout restart deployment/nats -n instant-data`.
166+
5. Update `instant-secrets` in `instant` namespace with the new seed.
167+
6. Restart api + worker so they pick up the new seed.
168+
169+
Old account JWTs (signed by the old operator) become invalid the moment the
170+
nats-server picks up the new operator key. Plan for a 5-minute brownout
171+
while every client reconnects.
172+
173+
## Failure modes
174+
175+
| Symptom | Cause | Fix |
176+
|---|---|---|
177+
| `nats-server: open /etc/nats/operator.conf: no such file` | initContainer didn't render the file | check `nats-operator` Secret exists in `instant-data` namespace, contents non-empty |
178+
| api logs `queueprovider.nats: parse operator seed: invalid encoded key` | `NATS_OPERATOR_SEED` not base64-encoded properly | re-do step 6 of the TL;DR |
179+
| Provisioned queue returns `connection_url` but `nats publish` fails with `Authorization Violation` | account claim never reached the resolver | check api logs for `queue.cred_issue_failed`; if the ResolverPusher fires no-op, that means the account JWT was minted but the resolver doesn't know about it yet — this is expected in MEMORY-resolver mode until we implement the SYS-connection push |
180+
| Existing pre-cutover queue resources stop working after rollout | nats-server in operator mode rejects all unauthenticated clients | this is the intended behavior; the legacy_open auth_mode flag in the DB is informational only — clients need to recycle into isolated mode after the cutover |
181+
182+
---
183+
184+
**Authoring:** generated 2026-05-20 by the NATS isolation cutover work. Keep
185+
this file in sync with `NATS-ISOLATION-MIGRATION-2026-05-20.md` (repo root)
186+
and `api/internal/handlers/queue_provider.go`.

0 commit comments

Comments
 (0)