feat(nats): per-license auth mechanism — agent user/password, scoped broker, generator (non-breaking)
All checks were successful
All checks were successful
Closes the open broker (anonymous publish to any tenant's corrosion.*).
Per-license isolation via NATS user/password + subject permissions:
each license -> user=license_id, password=HMAC-SHA256(license_id,
NATS_TOKEN_SECRET), scoped to corrosion.{license_id}.> + _INBOX. Backend
uses a privileged internal user.
- Agent (alpha.5): nats_user/nats_password config + env, user_and_password
auth; falls back to token/anonymous (transition-safe)
- Backend: connects with NATS_INTERNAL_USER/PASSWORD when set, else anon
- scripts/generate-nats-auth.mjs: regenerates nats-auth.conf from the
licenses table; NATS_AUTH_STAGE=open keeps a no_auth_user fallback
(verify creds first), =enforce rejects anonymous
- committed nats-auth.conf is the SAFE OPEN default (no secrets); the
host copy carries real users and is not committed
- compose: NATS_INTERNAL_USER/PASSWORD/NATS_TOKEN_SECRET, mount nats-auth.conf
Entirely non-breaking until secrets+config deployed; staged cutover next.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
85
scripts/generate-nats-auth.mjs
Normal file
85
scripts/generate-nats-auth.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/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):
|
||||
// "open" (default) — defines a full-access `anonymous` user and sets
|
||||
// no_auth_user, so unauthenticated clients still work.
|
||||
// Non-breaking; lets you verify real creds first.
|
||||
// "enforce" — omits no_auth_user; anonymous connections are rejected.
|
||||
//
|
||||
// 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 = 'open',
|
||||
} = 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.
|
||||
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}", "_INBOX.>"] }, ` +
|
||||
`subscribe: { allow: ["${scope}", "_INBOX.>"] } } }`,
|
||||
);
|
||||
}
|
||||
|
||||
if (NATS_AUTH_STAGE === 'open') {
|
||||
// Transition: unauthenticated clients map to a full-access user so nothing
|
||||
// breaks while real credentials roll out. Remove for enforcement.
|
||||
lines.push(' { user: "anonymous", password: "", permissions: { publish: ">", subscribe: ">" } }');
|
||||
}
|
||||
lines.push(' ]');
|
||||
if (NATS_AUTH_STAGE === 'open') {
|
||||
lines.push(' no_auth_user: "anonymous"');
|
||||
}
|
||||
lines.push('}');
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
};
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user