Turn lab IAM on, move portal to access-web, harden sessions and receipts.
Some checks are pending
offline / test (push) Waiting to run

Fleet and department doors now check STAFF_IAM_URL. Walkthrough: cs can
credit (agent is the IAM user) and cannot export; operator can fleet
POST; admin /me is owner. Portal is the public web door at :3021/portal/.
IAM sessions persist; login is rate-limited per user; receipt PDF is
branded. lan-134 stays disabled.
This commit is contained in:
George Lambert 2026-09-11 18:59:18 -04:00
parent d299d245e8
commit c32b65038a
20 changed files with 277 additions and 73 deletions

View file

@ -23,7 +23,7 @@ export async function iamCheck(req, permission) {
export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission);
if (out.ok) return true;
if (out.ok) return out;
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });

View file

@ -64,10 +64,12 @@ const server = http.createServer(async (req, res) => {
return json(r.status, body);
}
if (req.method === 'POST' && url.pathname === '/credits') {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
const who = await denyOrRedirect(req, res, json, { permission: 'cs.credit' });
if (!who) return;
const chunks = [];
for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
if (who.user?.username) body.agent = who.user.username;
const gate = await check('verae.billing.balance.adjust', { kind: 'credit', principal: body.agent });
if (!gate.allow) return json(403, gate);
const r = await fetch(`${BOOKS}/adjust`, {

View file

@ -9,7 +9,9 @@ Every NATS hop is `verae.access.web.*` → `verae.access.authz.check` → intern
| Route | Job |
|-------|-----|
| `GET /health` | `{ plane: "web" }` |
| `GET /portal/` | Customer portal (static) |
| `*` `/portal/api/*` | Proxy to loopback zappier-edge |
| `GET /statement/:id` | Authz then statement |
| `POST /reload` | Authz then `balance.adjust` kind=reload |
Port `:3021`.
Port `:3021`. Customer portal (public door): **http://0.0.0.0:3021/portal/** — static from `packages/zappier/portal`, `/portal/api` proxied to loopback zappier-edge. Edge itself stays on `127.0.0.1:3000`.

View file

@ -1,9 +1,69 @@
#!/usr/bin/env node
/** Direct customer web access. Not Zapier. NATS only after authz. */
/** Direct customer web access. Not Zapier. Serves the portal; APIs after authz. */
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PLANE, statement, reload } from './gate.js';
const PORT = Number(process.env.PORT || 3021);
const EDGE = (process.env.ZAPPIER_EDGE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PORTAL = process.env.PORTAL_STATIC || path.join(HERE, '..', '..', 'zappier', 'portal');
function mime(p) {
if (p.endsWith('.js')) return 'application/javascript; charset=utf-8';
if (p.endsWith('.css')) return 'text/css; charset=utf-8';
if (p.endsWith('.html')) return 'text/html; charset=utf-8';
if (p.endsWith('.svg')) return 'image/svg+xml';
if (p.endsWith('.json')) return 'application/json';
return 'application/octet-stream';
}
async function proxyPortalApi(req, res, url) {
const dest = `${EDGE}${url.pathname}${url.search}`;
const chunks = [];
for await (const c of req) chunks.push(c);
const r = await fetch(dest, {
method: req.method,
headers: {
'content-type': req.headers['content-type'] || 'application/json',
authorization: req.headers.authorization || '',
cookie: req.headers.cookie || '',
},
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
});
const buf = Buffer.from(await r.arrayBuffer());
const headers = { 'content-type': r.headers.get('content-type') || 'application/json' };
const setc = r.headers.get('set-cookie');
if (setc) headers['set-cookie'] = setc;
res.writeHead(r.status, headers);
res.end(buf);
}
function servePortal(req, res, url) {
let rel = url.pathname.replace(/^\/portal\/?/, '') || 'index.html';
if (rel.endsWith('/')) rel += 'index.html';
const file = path.normalize(path.join(PORTAL, rel));
if (!file.startsWith(path.normalize(PORTAL))) {
res.writeHead(403);
res.end('forbidden');
return;
}
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
const index = path.join(PORTAL, 'index.html');
if (fs.existsSync(index) && !path.extname(rel)) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(index));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
return;
}
res.writeHead(200, { 'content-type': mime(file) });
res.end(fs.readFileSync(file));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
@ -13,7 +73,17 @@ const server = http.createServer(async (req, res) => {
};
try {
if (req.method === 'GET' && url.pathname === '/health') {
return json(200, { ok: true, role: 'verae-access-web', plane: PLANE });
return json(200, { ok: true, role: 'verae-access-web', plane: PLANE, portal: fs.existsSync(PORTAL) });
}
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/portal')) {
res.writeHead(302, { location: '/portal/' });
return res.end();
}
if (url.pathname.startsWith('/portal/api')) {
return proxyPortalApi(req, res, url);
}
if (url.pathname.startsWith('/portal')) {
return servePortal(req, res, url);
}
const st = url.pathname.match(/^\/statement\/([^/]+)$/);
if (req.method === 'GET' && st) {
@ -34,5 +104,5 @@ const server = http.createServer(async (req, res) => {
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`verae-access-web http://0.0.0.0:${PORT}/ plane=${PLANE}\n`);
process.stdout.write(`verae-access-web http://0.0.0.0:${PORT}/ plane=${PLANE} portal=${PORTAL}\n`);
});

View file

@ -41,6 +41,9 @@ test('web plane can read statement after authz, cannot skip authz', async () =>
});
const h = await (await fetch(`http://127.0.0.1:${webPort}/health`)).json();
assert.equal(h.plane, 'web');
const portal = await fetch(`http://127.0.0.1:${webPort}/portal/`);
assert.equal(portal.status, 200);
assert.match(await portal.text(), /Zappier Portal|portal/i);
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');

View file

@ -17,7 +17,8 @@
"PORT": "3021",
"AUTHZ_URL": "http://127.0.0.1:3020",
"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",
"ZAPPIER_EDGE_URL": "http://127.0.0.1:3000"
},
"nats": {
"in": [],

View file

@ -23,7 +23,7 @@ export async function iamCheck(req, permission) {
export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission);
if (out.ok) return true;
if (out.ok) return out;
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });

View file

@ -16,7 +16,7 @@ Port **`:3028`**. UI: sign-in, people, roles, audit.
| `accounting` | `acct-dev-key` | accounting |
| `operator` | `fleet-dev-key` | operator |
Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH`.
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).
## Wire other doors

View file

@ -3,16 +3,17 @@ import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, expandRoles } from './roles.js';
import { allows, publicUser, ROLES, PERMISSIONS } from './roles.js';
import { verifyPassword } from './passwords.js';
import { loadIam, saveIam } from './store.js';
import {
issueSession,
getSession,
revokeSession,
cookieHeader,
clearCookieHeader,
tokenFromReq,
loginLocked,
loginFail,
loginOk,
clientKey,
} from './sessions.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -41,7 +42,7 @@ function readBody(req) {
function actorOf(req) {
const tok = tokenFromReq(req);
const s = getSession(tok);
const s = iam.getSession(tok);
if (!s) return null;
const u = iam.findById(s.userId);
if (!u || !u.active) return null;
@ -87,15 +88,22 @@ const server = http.createServer(async (req, res) => {
const username = String(body.username || '').trim();
const password = String(body.password || '');
const next = body.next || '/';
if (loginLocked(req, username)) {
iam.log(username || 'unknown', 'login.lock', clientKey(req));
saveIam(iam);
return json(429, { error: 'too many login attempts; try again in 10 minutes' });
}
const user = iam.findByUsername(username);
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
loginFail(req, username);
iam.log(username || 'unknown', 'login.fail', username);
saveIam(iam);
if ((req.headers['content-type'] || '').includes('json')) return json(401, { error: 'invalid username or password' });
res.writeHead(302, { location: '/login?error=1' });
return res.end();
}
const token = issueSession(user.id);
loginOk(req, username);
const token = iam.issueSession(user.id);
iam.log(username, 'login.ok', username);
saveIam(iam);
const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/';
@ -110,7 +118,8 @@ const server = http.createServer(async (req, res) => {
return res.end();
}
if (req.method === 'POST' && url.pathname === '/logout') {
revokeSession(tokenFromReq(req));
iam.revokeSession(tokenFromReq(req));
saveIam(iam);
if ((req.headers['content-type'] || '').includes('json')) {
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() });
return res.end(JSON.stringify({ ok: true }));

View file

@ -1,31 +1,6 @@
import { randomBytes } from 'node:crypto';
const TTL_MS = 12 * 60 * 60 * 1000;
const sessions = new Map();
export function issueSession(userId) {
const token = randomBytes(24).toString('hex');
sessions.set(token, { userId, exp: Date.now() + TTL_MS });
return token;
}
export function getSession(token) {
if (!token) return null;
const s = sessions.get(token);
if (!s) return null;
if (s.exp < Date.now()) {
sessions.delete(token);
return null;
}
return s;
}
export function revokeSession(token) {
if (token) sessions.delete(token);
}
export function cookieHeader(token) {
let s = `staff_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(TTL_MS / 1000)}`;
const ttl = 12 * 60 * 60;
let s = `staff_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`;
const domain = process.env.STAFF_COOKIE_DOMAIN;
if (domain) s += `; Domain=${domain}`;
if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure';
@ -46,3 +21,39 @@ export function tokenFromReq(req) {
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
return m ? m[1] : '';
}
const fails = new Map();
export function clientKey(req) {
return String(req.headers?.['x-forwarded-for'] || req.socket?.remoteAddress || 'local').split(',')[0].trim();
}
function failKey(req, username) {
return `${clientKey(req)}:${String(username || '').toLowerCase()}`;
}
/** True if this IP+username is currently locked out. */
export function loginLocked(req, username) {
const k = failKey(req, username);
const s = fails.get(k);
if (!s) return false;
if (Date.now() > s.until) {
fails.delete(k);
return false;
}
return s.n >= 8;
}
export function loginFail(req, username) {
const k = failKey(req, username);
const now = Date.now();
let s = fails.get(k);
if (!s || now > s.until) s = { n: 0, until: now + 10 * 60 * 1000 };
s.n += 1;
fails.set(k, s);
return s.n >= 8;
}
export function loginOk(req, username) {
fails.delete(failKey(req, username));
}

View file

@ -18,6 +18,8 @@ export class StaffIam {
this.users = [];
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
this.audit = [];
/** @type {Record<string,{userId:string,exp:number}>} */
this.sessions = {};
}
seed() {
@ -131,14 +133,47 @@ export class StaffIam {
return user;
}
issueSession(userId, ttlMs = 12 * 60 * 60 * 1000) {
const token = randomBytes(24).toString('hex');
this.sessions[token] = { userId, exp: Date.now() + ttlMs };
this.pruneSessions();
return token;
}
getSession(token) {
if (!token) return null;
this.pruneSessions();
const s = this.sessions[token];
if (!s) return null;
if (s.exp < Date.now()) {
delete this.sessions[token];
return null;
}
return s;
}
revokeSession(token) {
if (token) delete this.sessions[token];
}
pruneSessions() {
const now = Date.now();
for (const [k, s] of Object.entries(this.sessions)) {
if (!s || s.exp < now) delete this.sessions[k];
}
}
dump() {
return { users: this.users, audit: this.audit };
this.pruneSessions();
return { users: this.users, audit: this.audit, sessions: this.sessions };
}
load(raw) {
if (!raw || typeof raw !== 'object') return this;
this.users = Array.isArray(raw.users) ? raw.users : [];
this.audit = Array.isArray(raw.audit) ? raw.audit : [];
this.sessions = raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {};
this.pruneSessions();
return this;
}
}

View file

@ -12,6 +12,15 @@ test('owner expands to all permissions', () => {
assert.ok(ROLES.sales);
});
test('sessions persist in dump/load', () => {
const iam = new StaffIam().seed();
const u = iam.findByUsername('cs');
const tok = iam.issueSession(u.id);
const raw = iam.dump();
const b = new StaffIam().load(raw);
assert.equal(b.getSession(tok).userId, u.id);
});
test('cannot deactivate last owner; passwords hash', () => {
const iam = new StaffIam().seed();
const owner = iam.findByUsername('admin');

View file

@ -1,41 +1,66 @@
/**
* Minimal PDF builder for retrieval receipts (no native deps).
* Branded PDF retrieval receipt (no native deps).
* @module lib/receiptPdf
*/
/**
* @param {string} s
* @returns {string}
*/
function pdfEscape(s) {
return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function wrap(s, n) {
const str = String(s || '');
const out = [];
for (let i = 0; i < str.length; i += n) out.push(str.slice(i, i + n));
return out.length ? out : [''];
}
/**
* @param {object} receipt
* @returns {Buffer}
*/
export function buildReceiptPdf(receipt) {
const lines = [
'Verae Time — certified retrieval receipt',
`Type: ${receipt.type}`,
`Job ID: ${receipt.jobId}`,
`SHA256: ${receipt.sha256 ?? ''}`,
`Original timestamp: ${receipt.timestamp ?? ''}`,
`Retrieved at: ${receipt.extraSeal?.retrievedAt ?? ''}`,
`Tenant: ${receipt.extraSeal?.tenantId ?? ''}`,
`Seal event: ${receipt.extraSeal?.event ?? ''}`,
`Certificate: ${String(receipt.certificate ?? '').slice(0, 80)}`,
];
const lines = [];
const add = (label, value) => {
lines.push({ kind: 'label', text: label });
wrap(value, 86).forEach((t) => lines.push({ kind: 'value', text: t }));
lines.push({ kind: 'gap' });
};
add('Job ID', receipt.jobId);
add('SHA-256', receipt.sha256 ?? '');
add('Original timestamp', receipt.timestamp ?? '');
add('Retrieved at', receipt.extraSeal?.retrievedAt ?? '');
add('Tenant', receipt.extraSeal?.tenantId ?? '');
add('Seal event', receipt.extraSeal?.event ?? '');
add('Certificate', String(receipt.certificate ?? '').slice(0, 240));
const commands = lines
.map((line, i) => {
const y = 720 - i * 18;
return `BT /F1 11 Tf 50 ${y} Td (${pdfEscape(line)}) Tj ET`;
})
.join('\n');
const ops = [];
ops.push('0.192 0.180 0.506 rg');
ops.push('0 742 612 50 re f');
ops.push('1 1 1 rg');
ops.push('BT /F1 16 Tf 36 760 Td (Verae Time) Tj ET');
ops.push('BT /F1 9 Tf 36 746 Td (CERTIFIED RETRIEVAL RECEIPT) Tj ET');
ops.push('0.09 0.10 0.15 rg');
let y = 710;
for (const line of lines) {
if (line.kind === 'gap') {
y -= 8;
continue;
}
const size = line.kind === 'label' ? 8 : 11;
ops.push(`BT /F1 ${size} Tf 36 ${y} Td (${pdfEscape(line.text)}) Tj ET`);
y -= line.kind === 'label' ? 12 : 14;
}
ops.push('0.192 0.180 0.506 rg');
ops.push('36 48 540 0.8 re f');
ops.push('0.42 0.44 0.52 rg');
ops.push(
'BT /F1 8 Tf 36 34 Td (This is a certified retrieval receipt. The extra-seal event is recorded with the hash. It is not a substitute for the chain record.) Tj ET',
);
ops.push(
`BT /F1 8 Tf 36 22 Td (${pdfEscape('Verae Time x Zapier · type ' + (receipt.type || 'verae.retrieval-receipt'))}) Tj ET`,
);
const stream = `${commands}\n`;
const stream = `${ops.join('\n')}\n`;
const objects = [
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',

View file

@ -32,6 +32,13 @@
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
- SSH spawn timeout 8s; failed hosts skipped.
## 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 `*`.
- Portal public door `verae-access-web` `:3021/portal/` (static + `/portal/api` → loopback edge).
- IAM sessions persist in JSON; login rate-limit per IP+username (8/10min); credits stamp IAM username as authz principal.
- lan-134 remains disabled unless `FLEET_ENABLE_LAN134=1`. Receipt PDF branded (indigo header + legal footer).
## 2026-09-11 — staff IAM
- New `verae-staff-iam` :3028 — users, roles, permissions, sessions, audit UI.

View file

@ -6,4 +6,4 @@
- [x] Auth on CS/sales/accounting HTML via `verae-staff-session` (`STAFF_AUTH=1`).
- [x] Staff IAM: named users, roles, permissions (`verae-staff-iam` :3028).
- [ ] Zapier Platform `push` of a private app.
- [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge).
- [x] Move portal static files fully into `verae-access-web` (`/portal/` public door; API proxied to loopback edge).

View file

@ -23,7 +23,7 @@ export async function iamCheck(req, permission) {
export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission);
if (out.ok) return true;
if (out.ok) return out;
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });

View file

@ -23,7 +23,7 @@ export async function iamCheck(req, permission) {
export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission);
if (out.ok) return true;
if (out.ok) return out;
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });

View file

@ -53,6 +53,7 @@ const server = http.createServer(async (req, res) => {
return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
}
if (req.method === 'GET' && url.pathname === '/customers') {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
return json(200, { customers: await listCustomers(EDGE, KEY) });
}
const review = url.pathname.match(/^\/review\/([^/]+)$/);
@ -64,11 +65,13 @@ const server = http.createServer(async (req, res) => {
return json(out.status, out.body);
}
if (req.method === 'POST' && url.pathname === '/credits') {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
const who = await denyOrRedirect(req, res, json, { permission: 'cs.credit' });
if (!who) return;
const chunks = [];
for await (const c of req) chunks.push(c);
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
const nats = await billingRequest(SUBJECTS.BALANCE_ADJUST, { ...payload, kind: 'credit' });
if (who.user?.username) payload.agent = who.user.username;
const nats = await billingRequest(SUBJECTS.BALANCE_ADJUST, { ...payload, kind: 'credit', principal: payload.agent });
if (nats) return json(200, { ...nats, source: 'nats' });
const r = await fetch(`${BOOKS}/adjust`, {
method: 'POST',

View file

@ -23,7 +23,7 @@ export async function iamCheck(req, permission) {
export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission);
if (out.ok) return true;
if (out.ok) return out;
const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` });

View file

@ -70,6 +70,33 @@ export function adminAuth(): RequestHandler {
req.staff = sess;
return next();
}
const base = iamUrl();
if (base && token) {
void (async () => {
try {
const r = await fetch(`${base}/check`, { headers: { authorization: `Bearer ${token}` } });
if (!r.ok) {
res.status(403).json({ error: 'invalid or missing admin key' });
return;
}
const body = (await r.json()) as { user?: StaffSession };
if (body.user) {
req.staff = {
username: body.user.username,
name: body.user.name,
roles: body.user.roles,
permissions: body.user.permissions || [],
t: Date.now(),
};
return next();
}
} catch {
/* fall through */
}
res.status(403).json({ error: 'invalid or missing admin key' });
})();
return;
}
res.status(403).json({ error: 'invalid or missing admin key' });
};
}