Caught during the live cutover: nats-server rejects 'unknown field no_auth_user' when it is nested in the authorization block, taking the whole broker down. Both the generator (open stage) and the committed bootstrap default emitted it nested. Moved to top level. Enforce-stage output was unaffected (no no_auth_user), which is what the live broker now runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
99 lines
4.4 KiB
JavaScript
99 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Generate corrosion-nats authorization config from the licenses table.
|
|
//
|
|
// Per-license isolation without auth-callout: each license maps to a NATS user
|
|
// (user = license UUID, password = HMAC-SHA256(license_id, NATS_TOKEN_SECRET))
|
|
// whose publish/subscribe is restricted to corrosion.{license_id}.> (+ _INBOX
|
|
// for request-reply). The backend uses a privileged internal user.
|
|
//
|
|
// STAGING (NATS_AUTH_STAGE env) — defaults to "enforce" (secure by default):
|
|
// "enforce" (default) — no anonymous; unauthenticated connections rejected.
|
|
// "open" — EXPLICIT opt-in for a brief migration window. Maps
|
|
// anonymous to a HARMLESS namespace (corrosion.unclaimed.>),
|
|
// NEVER full access, so a stale "open" deploy cannot
|
|
// read or forge real tenant (corrosion.{uuid}.>) traffic.
|
|
//
|
|
// REPLY SUBJECTS: per-license users are scoped to corrosion.{license}.> ONLY —
|
|
// no _INBOX grant (that would let one license read another's request-reply
|
|
// responses). Backend→agent request-reply MUST therefore use a reply subject
|
|
// inside the license namespace, e.g. corrosion.{license}.reply.<id>, not the
|
|
// default global _INBOX. The agent simply responds to msg.reply, so no agent
|
|
// change is needed — the constraint is on the requester (the internal user has
|
|
// full > and is unaffected).
|
|
//
|
|
// Usage:
|
|
// DATABASE_URL=... NATS_INTERNAL_USER=... NATS_INTERNAL_PASSWORD=... \
|
|
// NATS_TOKEN_SECRET=... NATS_AUTH_STAGE=open node scripts/generate-nats-auth.mjs > docker/nats-auth.conf
|
|
//
|
|
// Re-run and reload NATS (`docker exec corrosion-nats nats-server --signal reload`)
|
|
// whenever licenses change.
|
|
|
|
import { createRequire } from 'node:module';
|
|
import { createHmac } from 'node:crypto';
|
|
|
|
const require = createRequire(new URL('../backend-nest/x.js', import.meta.url));
|
|
const { Client } = require('pg');
|
|
|
|
const {
|
|
DATABASE_URL,
|
|
NATS_INTERNAL_USER,
|
|
NATS_INTERNAL_PASSWORD,
|
|
NATS_TOKEN_SECRET,
|
|
NATS_AUTH_STAGE = 'enforce',
|
|
} = process.env;
|
|
|
|
for (const [k, v] of Object.entries({ DATABASE_URL, NATS_INTERNAL_USER, NATS_INTERNAL_PASSWORD, NATS_TOKEN_SECRET })) {
|
|
if (!v) { console.error(`Missing required env: ${k}`); process.exit(2); }
|
|
}
|
|
|
|
/** Per-license agent password — must match the backend's derivation. */
|
|
export function licensePassword(licenseId, secret) {
|
|
return createHmac('sha256', secret).update(licenseId).digest('hex');
|
|
}
|
|
|
|
const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
|
|
const main = async () => {
|
|
const pg = new Client({ connectionString: DATABASE_URL });
|
|
await pg.connect();
|
|
const { rows } = await pg.query('SELECT id FROM licenses ORDER BY created_at');
|
|
await pg.end();
|
|
|
|
const lines = [];
|
|
lines.push('# GENERATED by scripts/generate-nats-auth.mjs — do not edit by hand.');
|
|
lines.push(`# stage=${NATS_AUTH_STAGE} licenses=${rows.length}`);
|
|
lines.push('authorization {');
|
|
lines.push(' users: [');
|
|
// Privileged internal user — the backend (full corrosion.> + _INBOX + _SYS).
|
|
lines.push(` { user: "${esc(NATS_INTERNAL_USER)}", password: "${esc(NATS_INTERNAL_PASSWORD)}", permissions: { publish: ">", subscribe: ">" } }`);
|
|
|
|
// Per-license scoped users — corrosion.{id}.> ONLY. No _INBOX grant:
|
|
// replies ride the license namespace (see header). This is the whole
|
|
// point — one license can never touch another's subjects.
|
|
for (const { id } of rows) {
|
|
const pw = licensePassword(id, NATS_TOKEN_SECRET);
|
|
const scope = `corrosion.${id}.>`;
|
|
lines.push(
|
|
` { user: "${esc(id)}", password: "${esc(pw)}", permissions: { ` +
|
|
`publish: { allow: ["${scope}"] }, ` +
|
|
`subscribe: { allow: ["${scope}"] } } }`,
|
|
);
|
|
}
|
|
|
|
if (NATS_AUTH_STAGE === 'open') {
|
|
// EXPLICIT migration opt-in only. Anonymous gets a HARMLESS namespace —
|
|
// never real tenant subjects — so a stale "open" deploy leaks nothing.
|
|
lines.push(' { user: "anonymous", password: "", permissions: { publish: { allow: ["corrosion.unclaimed.>"] }, subscribe: { allow: ["corrosion.unclaimed.>"] } } }');
|
|
}
|
|
lines.push(' ]');
|
|
lines.push('}');
|
|
// no_auth_user is a TOP-LEVEL field, NOT inside authorization { } — nesting
|
|
// it makes nats-server reject the whole config ("unknown field no_auth_user").
|
|
if (NATS_AUTH_STAGE === 'open') {
|
|
lines.push('no_auth_user: "anonymous"');
|
|
}
|
|
process.stdout.write(lines.join('\n') + '\n');
|
|
};
|
|
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|