Initial import of verae-access-authz from zapier monorepo

This commit is contained in:
George Lambert 2026-09-11 16:19:36 -04:00
commit 481851d504
10 changed files with 476 additions and 0 deletions

11
NATS.md Normal file
View file

@ -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.

34
README.md Normal file
View file

@ -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.<plane>.<area>.<resource>.<action> (ingress, that plane only)
→ verae.access.authz.check (allow / deny)
→ verae.<area>.<resource>.<action> (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"}'
```

46
package-lock.json generated Normal file
View file

@ -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"
}
}
}

14
package.json Normal file
View file

@ -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"
}
}

38
src/client.js Normal file
View file

@ -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 };
}

100
src/policy.js Normal file
View file

@ -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 };
}

83
src/server.js Normal file
View file

@ -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`));

36
src/subjects.js Normal file
View file

@ -0,0 +1,36 @@
/**
* Access planes sit in front of internal verae.<area>.* addresses.
* Pattern:
* verae.access.<plane>.<area>.<resource>.<action> ingress (that plane only)
* verae.access.authz.check authorization step
* verae.<area>.<resource>.<action> 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 };
}

35
test/health.test.js Normal file
View file

@ -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');
}
});

79
test/policy.test.js Normal file
View file

@ -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);
});