commit 02676c101ca68f2e20860a33ca21d9e2802b3df6 Author: George Lambert Date: Fri Sep 11 18:27:15 2026 -0400 Initial import of verae-access-authz from zapier monorepo diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..10afef7 --- /dev/null +++ b/NATS.md @@ -0,0 +1,11 @@ +# NATS — verae-access-authz + +| Direction | Address | Kind | +|-----------|---------|------| +| IN | `verae.access.authz.check` | request-reply, queue `access-authz` | +| OUT | `verae.access.authz.deny` | pub on deny (audit) | + +Body in: `{ plane, subject, principal, kind, payload }`. +Body out: `{ allow, plane, subject, reason }`. + +Fail closed: if this process is down, access gateways must not forward. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c14cb93 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# verae-access-authz + +Authorization step on the NATS **address path**. Zapier is only **one** access plane. Direct web, customer API, and server-to-server leaf nodes are separate, and each is default-deny against internal subjects. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-access-authz +**Catalog:** https://zapier.georgelambert.org/packages/verae-access-authz/README.pdf + +## Address path + +```text +client + → verae.access.... (ingress, that plane only) + → verae.access.authz.check (allow / deny) + → verae... (internal bus) +``` + +Internal subjects (`verae.billing.*`, `verae.archive.*`, `verae.zapier.jobs.*`) stay stable. Planes never publish them until `authz.check` returns `allow`. + +| Plane | Who | May reach | +|-------|-----|-----------| +| `zapier` | Zapier Platform HTTPS | jobs.*, webhooks.deliver, billing.usage.recorded | +| `web` | Customer browser portal | statement.get, balance.adjust kind=reload\|payment | +| `api` | Customer `x-api-key` (not Zapier) | statement.get, usage.recorded, jobs.watch | +| `leaf` | S2S NATS leaf / mTLS | archive.*, jobs.*, webhooks — **not billing** | +| `staff` | CS / sales / admin | statement, balance.adjust (credits) | + +A leaf node cannot credit an account. A Zapier hop cannot read a customer statement. A browser cannot `archive.put`. + +```bash +NATS_URL=nats://127.0.0.1:4222 PORT=3020 npm start +curl -s http://127.0.0.1:3020/policy +curl -s -X POST http://127.0.0.1:3020/check -H 'content-type: application/json' \ + -d '{"plane":"leaf","subject":"verae.billing.balance.adjust"}' +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5b944ab --- /dev/null +++ b/package-lock.json @@ -0,0 +1,46 @@ +{ + "name": "verae-access-authz", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "verae-access-authz", + "version": "0.1.0", + "dependencies": { + "nats": "^2.28.2" + } + }, + "node_modules/nats": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/nats/-/nats-2.29.3.tgz", + "integrity": "sha512-tOQCRCwC74DgBTk4pWZ9V45sk4d7peoE2njVprMRCBXrhJ5q5cYM7i6W+Uvw2qUrcfOSnuisrX7bEx3b3Wx4QA==", + "deprecated": "Package moved. Use @nats-io/transport-node from https://github.com/nats-io/nats.js", + "license": "Apache-2.0", + "dependencies": { + "nkeys.js": "1.1.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/nkeys.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/nkeys.js/-/nkeys.js-1.1.0.tgz", + "integrity": "sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==", + "license": "Apache-2.0", + "dependencies": { + "tweetnacl": "1.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tweetnacl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", + "license": "Unlicense" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..850686f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "verae-access-authz", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "NATS authorization step for access planes (web, api, zapier, leaf, staff)", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "nats": "^2.28.2" + } +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..f443a9e --- /dev/null +++ b/src/client.js @@ -0,0 +1,38 @@ +/** Call verae.access.authz.check then the internal subject. Fail closed. */ +import { SUBJECTS } from './subjects.js'; + +export async function authorizeThenRequest(nc, sc, { plane, subject, principal, payload, timeout = 2000 }) { + const kind = payload?.kind; + const checkMsg = await nc.request( + SUBJECTS.AUTHZ_CHECK, + sc.encode(JSON.stringify({ plane, subject, principal, kind, payload })), + { timeout }, + ); + const decision = JSON.parse(sc.decode(checkMsg.data) || '{}'); + if (!decision.allow) { + const err = new Error(decision.reason || 'denied'); + err.decision = decision; + throw err; + } + const internal = decision.subject; + const body = { ...payload, plane, principal, traceId: payload?.traceId }; + const m = await nc.request(internal, sc.encode(JSON.stringify(body)), { timeout }); + return { decision, body: JSON.parse(sc.decode(m.data) || '{}') }; +} + +export async function authorizeThenPublish(nc, sc, { plane, subject, principal, payload, timeout = 2000 }) { + const kind = payload?.kind; + const checkMsg = await nc.request( + SUBJECTS.AUTHZ_CHECK, + sc.encode(JSON.stringify({ plane, subject, principal, kind, payload })), + { timeout }, + ); + const decision = JSON.parse(sc.decode(checkMsg.data) || '{}'); + if (!decision.allow) { + const err = new Error(decision.reason || 'denied'); + err.decision = decision; + throw err; + } + nc.publish(decision.subject, sc.encode(JSON.stringify({ ...payload, plane, principal }))); + return { decision }; +} diff --git a/src/policy.js b/src/policy.js new file mode 100644 index 0000000..1971275 --- /dev/null +++ b/src/policy.js @@ -0,0 +1,100 @@ +import { PLANES, parseAddress } from './subjects.js'; + +/** + * Default-deny. Each access plane may only touch listed internal prefixes. + * Internal workers (poller, WORM, account-balance) are not planes; they + * already sit on the private bus. This policy gates *ingress*. + */ +export const POLICY = Object.freeze({ + zapier: { + title: 'Zapier Platform (HTTPS only; never a NATS client)', + allow: [ + 'verae.zapier.jobs.watch', + 'verae.zapier.jobs.events', + 'verae.zapier.webhooks.deliver', + 'verae.billing.usage.recorded', + ], + }, + web: { + title: 'Direct customer web (portal browser)', + allow: [ + 'verae.billing.statement.get', + 'verae.billing.balance.adjust', + 'verae.billing.payment.recorded', + ], + adjustKinds: ['payment', 'reload'], + }, + api: { + title: 'Direct customer API (x-api-key, not Zapier)', + allow: ['verae.billing.statement.get', 'verae.billing.usage.recorded', 'verae.zapier.jobs.watch'], + }, + leaf: { + title: 'Server-to-server NATS leaf / mTLS', + allow: [ + 'verae.archive.put', + 'verae.archive.query', + 'verae.archive.reply.', + 'verae.zapier.jobs.watch', + 'verae.zapier.jobs.events', + 'verae.zapier.webhooks.deliver', + ], + }, + staff: { + title: 'CS / sales / admin / accounting web', + allow: [ + 'verae.billing.statement.get', + 'verae.billing.balance.get', + 'verae.billing.balance.adjust', + 'verae.billing.credit.applied', + 'verae.billing.payment.recorded', + ], + }, +}); + +function prefixAllowed(allow, subject) { + return allow.some((p) => (p.endsWith('.') ? subject.startsWith(p) : subject === p || subject.startsWith(`${p}.`))); +} + +/** + * @param {{ plane: string, subject: string, kind?: string, principal?: string }} req + */ +export function authorize(req) { + const plane = String(req?.plane || ''); + const parsed = parseAddress(req?.subject, plane); + if (parsed.authz) { + return deny(plane, req?.subject, 'authz subjects are not forwardable'); + } + if (!PLANES.includes(plane)) { + return deny(plane, parsed.internal, `unknown access plane`); + } + if (parsed.plane && parsed.plane !== plane) { + return deny(plane, parsed.internal, `plane mismatch (address is ${parsed.plane})`); + } + const internal = parsed.internal; + if (!internal.startsWith('verae.') || internal.startsWith('verae.access.')) { + return deny(plane, internal, 'not an internal verae.* subject'); + } + const rule = POLICY[plane]; + if (!prefixAllowed(rule.allow, internal)) { + return deny(plane, internal, `${plane} cannot reach ${internal}`); + } + if (internal === 'verae.billing.balance.adjust' && rule.adjustKinds) { + const kind = req?.kind || req?.payload?.kind; + if (kind && !rule.adjustKinds.includes(kind)) { + return deny(plane, internal, `${plane} cannot adjust kind=${kind}`); + } + } + return { + allow: true, + plane, + subject: internal, + principal: req?.principal || null, + veraeUserId: req?.veraeUserId || req?.payload?.veraeUserId || null, + traceId: req?.traceId || req?.payload?.traceId || null, + reason: 'ok', + }; +} + +function deny(plane, subject, reason) { + return { allow: false, plane: plane || null, subject: subject || null, reason }; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..3e29401 --- /dev/null +++ b/src/server.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import http from 'node:http'; +import { authorize, POLICY } from './policy.js'; +import { PLANES, SUBJECTS } from './subjects.js'; + +const PORT = Number(process.env.PORT || process.env.FLEET_HEALTH_PORT || 3020); +const BIND = process.env.FLEET_HEALTH_BIND || '0.0.0.0'; +let natsOk = false; + +async function startNats() { + const url = process.env.NATS_URL; + if (!url) return; + const { connect, StringCodec } = await import('nats'); + const nc = await connect({ servers: url.split(','), name: 'verae-access-authz' }); + const sc = StringCodec(); + const sub = nc.subscribe(SUBJECTS.AUTHZ_CHECK, { queue: SUBJECTS.QUEUE }); + (async () => { + for await (const m of sub) { + let payload = {}; + try { + payload = JSON.parse(sc.decode(m.data) || '{}'); + } catch { + payload = {}; + } + const out = authorize(payload); + if (!out.allow && m.reply) { + nc.publish(SUBJECTS.AUTHZ_DENY, sc.encode(JSON.stringify({ ...out, at: new Date().toISOString() }))); + } + if (m.reply) m.respond(sc.encode(JSON.stringify(out))); + } + })(); + natsOk = true; + process.stdout.write(`access-authz nats ${url}\n`); +} + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); + } catch { + resolve({}); + } + }); + }); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(JSON.stringify(obj)); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { + ok: true, + role: 'verae-access-authz', + nats: natsOk, + planes: PLANES, + subject: SUBJECTS.AUTHZ_CHECK, + }); + } + if (req.method === 'GET' && url.pathname === '/policy') { + return json(200, { planes: PLANES, policy: POLICY, subjects: SUBJECTS }); + } + if (req.method === 'POST' && url.pathname === '/check') { + const body = await readBody(req); + const out = authorize(body); + return json(out.allow ? 200 : 403, out); + } + json(404, { error: 'not found' }); + } catch (err) { + json(500, { error: err.message }); + } +}); + +server.listen(PORT, BIND, () => { + process.stdout.write(`verae-access-authz http://${BIND}:${PORT}/\n`); +}); +startNats().catch((err) => process.stderr.write(`nats optional: ${err.message}\n`)); diff --git a/src/subjects.js b/src/subjects.js new file mode 100644 index 0000000..029865d --- /dev/null +++ b/src/subjects.js @@ -0,0 +1,36 @@ +/** + * Access planes sit in front of internal verae..* addresses. + * Pattern: + * verae.access.... ingress (that plane only) + * verae.access.authz.check authorization step + * verae... internal (unchanged) + */ +export const PLANES = Object.freeze(['zapier', 'web', 'api', 'leaf', 'staff']); + +export const SUBJECTS = Object.freeze({ + AUTHZ_CHECK: 'verae.access.authz.check', + AUTHZ_DENY: 'verae.access.authz.deny', + QUEUE: 'access-authz', + ingress: (plane) => `verae.access.${plane}.in`, +}); + +/** verae.billing.statement.get + plane web → verae.access.web.billing.statement.get */ +export function accessAddress(plane, internalSubject) { + if (!PLANES.includes(plane)) throw new Error(`unknown plane ${plane}`); + if (!internalSubject.startsWith('verae.') || internalSubject.startsWith('verae.access.')) { + throw new Error(`not an internal verae.* subject: ${internalSubject}`); + } + return `verae.access.${plane}.${internalSubject.slice('verae.'.length)}`; +} + +/** Parse an access or internal subject into { plane, internal }. */ +export function parseAddress(subject, planeHint) { + const s = String(subject || ''); + const m = s.match(/^verae\.access\.([a-z]+)\.(.+)$/); + if (m) { + const plane = m[1]; + if (plane === 'authz') return { plane: planeHint || null, internal: s, authz: true }; + return { plane, internal: `verae.${m[2]}`, authz: false }; + } + return { plane: planeHint || null, internal: s, authz: false }; +} diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..6b2d967 --- /dev/null +++ b/test/health.test.js @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('authz health policy and check', async () => { + const port = 18020; + const child = spawn(process.execPath, ['src/server.js'], { + cwd: root, + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 400)); + try { + const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json(); + assert.equal(h.role, 'verae-access-authz'); + const ok = await fetch(`http://127.0.0.1:${port}/check`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ plane: 'web', subject: 'verae.billing.statement.get' }), + }); + assert.equal(ok.status, 200); + const no = await fetch(`http://127.0.0.1:${port}/check`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ plane: 'leaf', subject: 'verae.billing.balance.adjust' }), + }); + assert.equal(no.status, 403); + } finally { + child.kill('SIGTERM'); + } +}); diff --git a/test/policy.test.js b/test/policy.test.js new file mode 100644 index 0000000..f0592d5 --- /dev/null +++ b/test/policy.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { authorize } from '../src/policy.js'; +import { accessAddress, parseAddress, PLANES } from '../src/subjects.js'; + +test('five access planes', () => { + assert.deepEqual([...PLANES], ['zapier', 'web', 'api', 'leaf', 'staff']); +}); + +test('web may read statement and reload, not archive or jobs', () => { + assert.equal(authorize({ plane: 'web', subject: 'verae.billing.statement.get' }).allow, true); + assert.equal( + authorize({ plane: 'web', subject: 'verae.billing.balance.adjust', kind: 'reload' }).allow, + true, + ); + assert.equal( + authorize({ plane: 'web', subject: 'verae.billing.balance.adjust', kind: 'credit' }).allow, + false, + ); + assert.equal(authorize({ plane: 'web', subject: 'verae.archive.put' }).allow, false); + assert.equal(authorize({ plane: 'web', subject: 'verae.zapier.jobs.watch' }).allow, false); +}); + +test('leaf may archive and jobs, not billing adjust', () => { + assert.equal(authorize({ plane: 'leaf', subject: 'verae.archive.put' }).allow, true); + assert.equal(authorize({ plane: 'leaf', subject: 'verae.archive.reply.abc' }).allow, true); + assert.equal(authorize({ plane: 'leaf', subject: 'verae.zapier.jobs.watch' }).allow, true); + assert.equal(authorize({ plane: 'leaf', subject: 'verae.billing.balance.adjust' }).allow, false); + assert.equal(authorize({ plane: 'leaf', subject: 'verae.billing.statement.get' }).allow, false); +}); + +test('zapier may meter and jobs, not customer statement or credits', () => { + assert.equal(authorize({ plane: 'zapier', subject: 'verae.zapier.jobs.watch' }).allow, true); + assert.equal(authorize({ plane: 'zapier', subject: 'verae.billing.usage.recorded' }).allow, true); + assert.equal(authorize({ plane: 'zapier', subject: 'verae.billing.statement.get' }).allow, false); + assert.equal(authorize({ plane: 'zapier', subject: 'verae.billing.balance.adjust' }).allow, false); + assert.equal(authorize({ plane: 'zapier', subject: 'verae.archive.put' }).allow, false); +}); + +test('api may statement and usage, not credits or archive', () => { + assert.equal(authorize({ plane: 'api', subject: 'verae.billing.statement.get' }).allow, true); + assert.equal(authorize({ plane: 'api', subject: 'verae.billing.usage.recorded' }).allow, true); + assert.equal(authorize({ plane: 'api', subject: 'verae.billing.balance.adjust' }).allow, false); + assert.equal(authorize({ plane: 'api', subject: 'verae.archive.query' }).allow, false); +}); + +test('staff may credit and statement, not archive', () => { + assert.equal(authorize({ plane: 'staff', subject: 'verae.billing.balance.adjust', kind: 'credit' }).allow, true); + assert.equal(authorize({ plane: 'staff', subject: 'verae.billing.statement.get' }).allow, true); + assert.equal(authorize({ plane: 'staff', subject: 'verae.archive.put' }).allow, false); +}); + +test('access-prefixed address is mapped to internal', () => { + const addr = accessAddress('leaf', 'verae.archive.put'); + assert.equal(addr, 'verae.access.leaf.archive.put'); + const parsed = parseAddress(addr); + assert.equal(parsed.plane, 'leaf'); + assert.equal(parsed.internal, 'verae.archive.put'); + assert.equal(authorize({ plane: 'leaf', subject: addr }).allow, true); + assert.equal(authorize({ plane: 'web', subject: addr }).allow, false); +}); + +test('allow echoes veraeUserId for hop tracing', () => { + const out = authorize({ + plane: 'web', + subject: 'verae.billing.statement.get', + principal: 'cust_1', + veraeUserId: 'vu_deadbeefdeadbeef', + traceId: 'tr-1', + }); + assert.equal(out.allow, true); + assert.equal(out.veraeUserId, 'vu_deadbeefdeadbeef'); + assert.equal(out.traceId, 'tr-1'); +}); + +test('unknown plane and authz subjects denied', () => { + assert.equal(authorize({ plane: 'partner', subject: 'verae.archive.put' }).allow, false); + assert.equal(authorize({ plane: 'web', subject: 'verae.access.authz.check' }).allow, false); +});