Close remaining IAM JSON doors and re-walk the lab.
Some checks are pending
offline / test (push) Waiting to run

CS/sales/accounting/staff list endpoints and sales pricing writes
require IAM; fleet service env sets STAFF_AUTH=1. Department tests
use a private books file so lab NATS does not leak into them.
This commit is contained in:
George Lambert 2026-09-11 19:08:20 -04:00
parent c32b65038a
commit 2bb3884a1b
14 changed files with 37 additions and 8 deletions

View file

@ -51,6 +51,7 @@ const server = http.createServer(async (req, res) => {
return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE }); return json(200, { ok: true, role: 'verae-access-staff', plane: PLANE });
} }
if (req.method === 'GET' && url.pathname === '/customers') { if (req.method === 'GET' && url.pathname === '/customers') {
if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane' }))) return;
return json(200, { customers: await listCustomers(EDGE, KEY) }); return json(200, { customers: await listCustomers(EDGE, KEY) });
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);

View file

@ -1,6 +1,8 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -12,14 +14,15 @@ test('web plane can read statement after authz, cannot skip authz', async () =>
const authzPort = 18021; const authzPort = 18021;
const booksPort = 18022; const booksPort = 18022;
const webPort = 18023; const webPort = 18023;
const booksFile = path.join(os.tmpdir(), `web-books-${process.pid}.json`);
const authz = spawn(process.execPath, ['src/server.js'], { const authz = spawn(process.execPath, ['src/server.js'], {
cwd: authzRoot, cwd: authzRoot,
env: { ...process.env, PORT: String(authzPort) }, env: { ...process.env, PORT: String(authzPort), NATS_URL: '' },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const books = spawn(process.execPath, ['src/server.js'], { const books = spawn(process.execPath, ['src/server.js'], {
cwd: booksRoot, cwd: booksRoot,
env: { ...process.env, PORT: String(booksPort) }, env: { ...process.env, PORT: String(booksPort), BOOKS_PATH: booksFile, NATS_URL: '' },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const web = spawn(process.execPath, ['src/server.js'], { const web = spawn(process.execPath, ['src/server.js'], {
@ -29,6 +32,7 @@ test('web plane can read statement after authz, cannot skip authz', async () =>
PORT: String(webPort), PORT: String(webPort),
AUTHZ_URL: `http://127.0.0.1:${authzPort}`, AUTHZ_URL: `http://127.0.0.1:${authzPort}`,
ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`, ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`,
NATS_URL: '',
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
@ -51,5 +55,6 @@ test('web plane can read statement after authz, cannot skip authz', async () =>
web.kill('SIGTERM'); web.kill('SIGTERM');
books.kill('SIGTERM'); books.kill('SIGTERM');
authz.kill('SIGTERM'); authz.kill('SIGTERM');
fs.rmSync(booksFile, { force: true });
} }
}); });

View file

@ -17,7 +17,8 @@
"PORT": "3025", "PORT": "3025",
"AUTHZ_URL": "http://127.0.0.1:3020", "AUTHZ_URL": "http://127.0.0.1:3020",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"STAFF_IAM_URL": "http://127.0.0.1:3028" "STAFF_IAM_URL": "http://127.0.0.1:3028",
"STAFF_AUTH": "1"
}, },
"nats": { "in": [], "out": ["verae.access.authz.check"] } "nats": { "in": [], "out": ["verae.access.authz.check"] }
} }

View file

@ -18,7 +18,8 @@
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222", "NATS_URL": "nats://127.0.0.1:4222",
"STAFF_IAM_URL": "http://127.0.0.1:3028" "STAFF_IAM_URL": "http://127.0.0.1:3028",
"STAFF_AUTH": "1"
}, },
"nats": { "in": [], "out": ["verae.billing.statement.get"] } "nats": { "in": [], "out": ["verae.billing.statement.get"] }
} }

View file

@ -19,7 +19,8 @@
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222", "NATS_URL": "nats://127.0.0.1:4222",
"AUTHZ_URL": "http://127.0.0.1:3020", "AUTHZ_URL": "http://127.0.0.1:3020",
"STAFF_IAM_URL": "http://127.0.0.1:3028" "STAFF_IAM_URL": "http://127.0.0.1:3028",
"STAFF_AUTH": "1"
}, },
"nats": { "nats": {
"in": [], "in": [],

View file

@ -19,7 +19,8 @@
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222", "NATS_URL": "nats://127.0.0.1:4222",
"AUTHZ_URL": "http://127.0.0.1:3020", "AUTHZ_URL": "http://127.0.0.1:3020",
"STAFF_IAM_URL": "http://127.0.0.1:3028" "STAFF_IAM_URL": "http://127.0.0.1:3028",
"STAFF_AUTH": "1"
}, },
"nats": { "in": [], "out": ["verae.billing.statement.get"] } "nats": { "in": [], "out": ["verae.billing.statement.get"] }
} }

View file

@ -16,6 +16,7 @@
"env": { "env": {
"PORT": "3000", "PORT": "3000",
"STAFF_IAM_URL": "http://127.0.0.1:3028", "STAFF_IAM_URL": "http://127.0.0.1:3028",
"STAFF_AUTH": "1",
"BIND": "127.0.0.1", "BIND": "127.0.0.1",
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100", "ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
"CS_SERVICE_URL": "http://127.0.0.1:3011", "CS_SERVICE_URL": "http://127.0.0.1:3011",

View file

@ -16,7 +16,7 @@ Port **`:3028`**. UI: sign-in, people, roles, audit.
| `accounting` | `acct-dev-key` | accounting | | `accounting` | `acct-dev-key` | accounting |
| `operator` | `fleet-dev-key` | operator | | `operator` | `fleet-dev-key` | operator |
Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH` (users, audit, **sessions**). Login is rate-limited (8 failures / 10 minutes / IP). Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH` (users, audit, **sessions**). Login is rate-limited (8 failures / 10 minutes / IP+username). JSON APIs return 401/403; HTML doors 302 to `/login`.
## Wire other doors ## Wire other doors

View file

@ -32,6 +32,12 @@
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`. - Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
- SSH spawn timeout 8s; failed hosts skipped. - SSH spawn timeout 8s; failed hosts skipped.
## 2026-09-11 — IAM JSON doors closed
- CS/sales/accounting/staff JSON list and sales PUT pricing now go through IAM (401 JSON, 302 HTML).
- Fleet service env sets `STAFF_AUTH=1` with `STAFF_IAM_URL`.
- Re-walk: cs credit 200 agent=`cs`; cs export 403; operator Fleet POST 200; admin `/me` `permissions:['*']`.
## 2026-09-11 — IAM on lab, portal door, hardening ## 2026-09-11 — IAM on lab, portal door, hardening
- Restarted fleet with `STAFF_IAM_URL`. Walk: cs credits (agent=`cs`) and is 403 on accounting export; operator fleet POST 200, cs 403; admin `/me` permissions `*`. - Restarted fleet with `STAFF_IAM_URL`. Walk: cs credits (agent=`cs`) and is 403 on accounting export; operator fleet POST 200, cs 403; admin `/me` permissions `*`.

View file

@ -7,3 +7,5 @@
- [x] Staff IAM: named users, roles, permissions (`verae-staff-iam` :3028). - [x] Staff IAM: named users, roles, permissions (`verae-staff-iam` :3028).
- [ ] Zapier Platform `push` of a private app. - [ ] Zapier Platform `push` of a private app.
- [x] Move portal static files fully into `verae-access-web` (`/portal/` public door; API proxied to loopback edge). - [x] Move portal static files fully into `verae-access-web` (`/portal/` public door; API proxied to loopback edge).
- [x] Turn IAM on for the running lab (fleet `STAFF_IAM_URL` + restart).
- [x] IAM hardening: JSON sessions, login rate-limit, JSON 401 as well as HTML 302, credit principal = IAM username.

View file

@ -35,6 +35,7 @@ const server = http.createServer(async (req, res) => {
return json(200, { ok: true, role: 'zappier-accounting-export' }); return json(200, { ok: true, role: 'zappier-accounting-export' });
} }
if (req.method === 'GET' && url.pathname === '/customers') { if (req.method === 'GET' && url.pathname === '/customers') {
if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review' }))) return;
return json(200, { customers: await listCustomers(EDGE, KEY) }); return json(200, { customers: await listCustomers(EDGE, KEY) });
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);

View file

@ -87,6 +87,7 @@ const server = http.createServer(async (req, res) => {
return json(r.status, body); return json(r.status, body);
} }
if (req.method === 'GET' && url.pathname === '/credits') { if (req.method === 'GET' && url.pathname === '/credits') {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
const id = url.searchParams.get('customerId'); const id = url.searchParams.get('customerId');
if (!id) return json(400, { error: 'customerId required' }); if (!id) return json(400, { error: 'customerId required' });
const out = await statement(id); const out = await statement(id);

View file

@ -2,6 +2,8 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
const root = path.dirname(fileURLToPath(new URL('.', import.meta.url))); const root = path.dirname(fileURLToPath(new URL('.', import.meta.url)));
@ -29,9 +31,10 @@ test('customer-service health', async () => {
test('customer-service review via account-balance HTTP', async () => { test('customer-service review via account-balance HTTP', async () => {
const booksPort = 18014; const booksPort = 18014;
const csPort = 18015; const csPort = 18015;
const booksFile = path.join(os.tmpdir(), `cs-books-${process.pid}.json`);
const books = spawn(process.execPath, ['src/server.js'], { const books = spawn(process.execPath, ['src/server.js'], {
cwd: path.join(root, '..', 'zappier-account-balance'), cwd: path.join(root, '..', 'zappier-account-balance'),
env: { ...process.env, PORT: String(booksPort) }, env: { ...process.env, PORT: String(booksPort), BOOKS_PATH: booksFile, NATS_URL: '' },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const cs = spawn(process.execPath, ['src/server.js'], { const cs = spawn(process.execPath, ['src/server.js'], {
@ -40,6 +43,8 @@ test('customer-service review via account-balance HTTP', async () => {
...process.env, ...process.env,
PORT: String(csPort), PORT: String(csPort),
ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`, ACCOUNT_BALANCE_URL: `http://127.0.0.1:${booksPort}`,
NATS_URL: '',
STAFF_IAM_URL: '',
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
@ -58,5 +63,6 @@ test('customer-service review via account-balance HTTP', async () => {
} finally { } finally {
cs.kill('SIGTERM'); cs.kill('SIGTERM');
books.kill('SIGTERM'); books.kill('SIGTERM');
fs.rmSync(booksFile, { force: true });
} }
}); });

View file

@ -47,6 +47,7 @@ const server = http.createServer(async (req, res) => {
return json(200, { ok: true, role: 'zappier-sales-pricing' }); return json(200, { ok: true, role: 'zappier-sales-pricing' });
} }
if (req.method === 'GET' && url.pathname === '/customers') { if (req.method === 'GET' && url.pathname === '/customers') {
if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review' }))) return;
return json(200, { customers: await listCustomers(EDGE, KEY) }); return json(200, { customers: await listCustomers(EDGE, KEY) });
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);
@ -68,6 +69,7 @@ const server = http.createServer(async (req, res) => {
} }
const price = url.pathname.match(/^\/customers\/([^/]+)\/pricing$/); const price = url.pathname.match(/^\/customers\/([^/]+)\/pricing$/);
if (req.method === 'PUT' && price) { if (req.method === 'PUT' && price) {
if (!(await denyOrRedirect(req, res, json, { permission: 'sales.quote' }))) return;
const chunks = []; const chunks = [];
for await (const c of req) chunks.push(c); for await (const c of req) chunks.push(c);
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');