#!/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., 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); });