Separate Zapier, web, API, and leaf access planes with NATS authz
Some checks are pending
offline / test (push) Waiting to run
Some checks are pending
offline / test (push) Waiting to run
Zapier is one ingress. Direct web, customer API, and S2S leaf nodes are their own services. Every hop to an internal subject must pass verae.access.authz.check (default deny by plane).
This commit is contained in:
parent
ac38676645
commit
1b199ca4d4
117 changed files with 2640 additions and 105 deletions
3
packages/verae-access-web/NATS.md
Normal file
3
packages/verae-access-web/NATS.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# NATS — verae-access-web
|
||||
|
||||
Plane `web`. Ingress `verae.access.web.in` / `verae.access.web.billing.*`. Must pass `verae.access.authz.check`. Internal: `verae.billing.statement.get`, `verae.billing.balance.adjust` (reload/payment only).
|
||||
15
packages/verae-access-web/README.md
Normal file
15
packages/verae-access-web/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# verae-access-web
|
||||
|
||||
**Direct web** access plane. Customer browsers hit this process, not Zapier and not the NATS port.
|
||||
|
||||
**Forgejo:** https://git.georgelambert.org/marchon/verae-access-web
|
||||
|
||||
Every NATS hop is `verae.access.web.*` → `verae.access.authz.check` → internal `verae.billing.statement.get` (reload only, not CS credits).
|
||||
|
||||
| Route | Job |
|
||||
|-------|-----|
|
||||
| `GET /health` | `{ plane: "web" }` |
|
||||
| `GET /statement/:id` | Authz then statement |
|
||||
| `POST /reload` | Authz then `balance.adjust` kind=reload |
|
||||
|
||||
Port `:3021`.
|
||||
46
packages/verae-access-web/package-lock.json
generated
Normal file
46
packages/verae-access-web/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "verae-access-web",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "verae-access-web",
|
||||
"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
packages/verae-access-web/package.json
Normal file
14
packages/verae-access-web/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "verae-access-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Direct customer web access plane (not Zapier)",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"nats": "^2.28.2"
|
||||
}
|
||||
}
|
||||
50
packages/verae-access-web/src/gate.js
Normal file
50
packages/verae-access-web/src/gate.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
const AUTHZ = (process.env.AUTHZ_URL || 'http://127.0.0.1:3020').replace(/\/$/, '');
|
||||
const BOOKS = (process.env.ACCOUNT_BALANCE_URL || 'http://127.0.0.1:3010').replace(/\/$/, '');
|
||||
|
||||
export const PLANE = 'web';
|
||||
export const AUTHZ_CHECK = 'verae.access.authz.check';
|
||||
|
||||
export async function check(subject, { principal, kind, payload } = {}) {
|
||||
const r = await fetch(`${AUTHZ}/check`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ plane: PLANE, subject, principal, kind, payload }),
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => ({ allow: false })) };
|
||||
}
|
||||
|
||||
export async function statement(customerId, principal) {
|
||||
const gate = await check('verae.billing.statement.get', { principal });
|
||||
if (!gate.body.allow) return { status: 403, body: gate.body };
|
||||
const url = process.env.NATS_URL;
|
||||
if (url) {
|
||||
try {
|
||||
const { connect, StringCodec } = await import('nats');
|
||||
const nc = await connect({ servers: url.split(','), name: 'verae-access-web' });
|
||||
const sc = StringCodec();
|
||||
const m = await nc.request(
|
||||
'verae.billing.statement.get',
|
||||
sc.encode(JSON.stringify({ customerId, plane: PLANE, principal })),
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
const out = JSON.parse(sc.decode(m.data) || '{}');
|
||||
await nc.close();
|
||||
return { status: 200, body: { ...out, source: 'nats', plane: PLANE } };
|
||||
} catch {
|
||||
/* HTTP fallback */
|
||||
}
|
||||
}
|
||||
const r = await fetch(`${BOOKS}/statement/${customerId}`);
|
||||
return { status: r.status, body: { ...(await r.json().catch(() => ({}))), source: 'account-balance', plane: PLANE } };
|
||||
}
|
||||
|
||||
export async function reload(customerId, cents, principal) {
|
||||
const gate = await check('verae.billing.balance.adjust', { principal, kind: 'reload' });
|
||||
if (!gate.body.allow) return { status: 403, body: gate.body };
|
||||
const r = await fetch(`${BOOKS}/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ customerId, cents, reason: 'reload', agent: principal || 'web', kind: 'reload' }),
|
||||
});
|
||||
return { status: r.status, body: { ...(await r.json().catch(() => ({}))), plane: PLANE } };
|
||||
}
|
||||
38
packages/verae-access-web/src/server.js
Normal file
38
packages/verae-access-web/src/server.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env node
|
||||
/** Direct customer web access. Not Zapier. NATS only after authz. */
|
||||
import http from 'node:http';
|
||||
import { PLANE, statement, reload } from './gate.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 3021);
|
||||
|
||||
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-web', plane: PLANE });
|
||||
}
|
||||
const st = url.pathname.match(/^\/statement\/([^/]+)$/);
|
||||
if (req.method === 'GET' && st) {
|
||||
const out = await statement(st[1], st[1]);
|
||||
return json(out.status, out.body);
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/reload') {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
const out = await reload(body.customerId, body.cents, body.customerId);
|
||||
return json(out.status, out.body);
|
||||
}
|
||||
json(404, { error: 'not found' });
|
||||
} catch (err) {
|
||||
json(502, { error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
process.stdout.write(`verae-access-web http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
|
||||
});
|
||||
52
packages/verae-access-web/test/health.test.js
Normal file
52
packages/verae-access-web/test/health.test.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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)), '..');
|
||||
const authzRoot = path.join(root, '..', 'verae-access-authz');
|
||||
const booksRoot = path.join(root, '..', 'zappier-account-balance');
|
||||
|
||||
test('web plane can read statement after authz, cannot skip authz', async () => {
|
||||
const authzPort = 18021;
|
||||
const booksPort = 18022;
|
||||
const webPort = 18023;
|
||||
const authz = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: authzRoot,
|
||||
env: { ...process.env, PORT: String(authzPort) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const books = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: booksRoot,
|
||||
env: { ...process.env, PORT: String(booksPort) },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const web = spawn(process.execPath, ['src/server.js'], {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(webPort),
|
||||
AUTHZ_URL: `http://127.0.0.1:${authzPort}`,
|
||||
ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${booksPort}/adjust`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ customerId: 'c-web', cents: 400, kind: 'reload' }),
|
||||
});
|
||||
const h = await (await fetch(`http://127.0.0.1:${webPort}/health`)).json();
|
||||
assert.equal(h.plane, 'web');
|
||||
const st = await (await fetch(`http://127.0.0.1:${webPort}/statement/c-web`)).json();
|
||||
assert.equal(st.prepaidCents, 400);
|
||||
assert.equal(st.plane, 'web');
|
||||
} finally {
|
||||
web.kill('SIGTERM');
|
||||
books.kill('SIGTERM');
|
||||
authz.kill('SIGTERM');
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue