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 }) { export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission); 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(/\/$/, ''); const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) { if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); 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); return json(r.status, body);
} }
if (req.method === 'POST' && url.pathname === '/credits') { 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 = []; const chunks = [];
for await (const c of req) chunks.push(c); for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); 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 }); const gate = await check('verae.billing.balance.adjust', { kind: 'credit', principal: body.agent });
if (!gate.allow) return json(403, gate); if (!gate.allow) return json(403, gate);
const r = await fetch(`${BOOKS}/adjust`, { 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 | | Route | Job |
|-------|-----| |-------|-----|
| `GET /health` | `{ plane: "web" }` | | `GET /health` | `{ plane: "web" }` |
| `GET /portal/` | Customer portal (static) |
| `*` `/portal/api/*` | Proxy to loopback zappier-edge |
| `GET /statement/:id` | Authz then statement | | `GET /statement/:id` | Authz then statement |
| `POST /reload` | Authz then `balance.adjust` kind=reload | | `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 #!/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 http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { PLANE, statement, reload } from './gate.js'; import { PLANE, statement, reload } from './gate.js';
const PORT = Number(process.env.PORT || 3021); 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 server = http.createServer(async (req, res) => {
const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`);
@ -13,7 +73,17 @@ const server = http.createServer(async (req, res) => {
}; };
try { try {
if (req.method === 'GET' && url.pathname === '/health') { 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\/([^/]+)$/); const st = url.pathname.match(/^\/statement\/([^/]+)$/);
if (req.method === 'GET' && st) { if (req.method === 'GET' && st) {
@ -34,5 +104,5 @@ const server = http.createServer(async (req, res) => {
}); });
server.listen(PORT, '0.0.0.0', () => { 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(); const h = await (await fetch(`http://127.0.0.1:${webPort}/health`)).json();
assert.equal(h.plane, 'web'); 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(); const st = await (await fetch(`http://127.0.0.1:${webPort}/statement/c-web`)).json();
assert.equal(st.prepaidCents, 400); assert.equal(st.prepaidCents, 400);
assert.equal(st.plane, 'web'); assert.equal(st.plane, 'web');

View file

@ -17,7 +17,8 @@
"PORT": "3021", "PORT": "3021",
"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",
"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": { "nats": {
"in": [], "in": [],

View file

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

View file

@ -3,16 +3,17 @@ import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { verifyPassword } from './passwords.js';
import { loadIam, saveIam } from './store.js'; import { loadIam, saveIam } from './store.js';
import { import {
issueSession,
getSession,
revokeSession,
cookieHeader, cookieHeader,
clearCookieHeader, clearCookieHeader,
tokenFromReq, tokenFromReq,
loginLocked,
loginFail,
loginOk,
clientKey,
} from './sessions.js'; } from './sessions.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -41,7 +42,7 @@ function readBody(req) {
function actorOf(req) { function actorOf(req) {
const tok = tokenFromReq(req); const tok = tokenFromReq(req);
const s = getSession(tok); const s = iam.getSession(tok);
if (!s) return null; if (!s) return null;
const u = iam.findById(s.userId); const u = iam.findById(s.userId);
if (!u || !u.active) return null; if (!u || !u.active) return null;
@ -87,15 +88,22 @@ const server = http.createServer(async (req, res) => {
const username = String(body.username || '').trim(); const username = String(body.username || '').trim();
const password = String(body.password || ''); const password = String(body.password || '');
const next = body.next || '/'; 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); const user = iam.findByUsername(username);
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) { if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
loginFail(req, username);
iam.log(username || 'unknown', 'login.fail', username); iam.log(username || 'unknown', 'login.fail', username);
saveIam(iam); saveIam(iam);
if ((req.headers['content-type'] || '').includes('json')) return json(401, { error: 'invalid username or password' }); if ((req.headers['content-type'] || '').includes('json')) return json(401, { error: 'invalid username or password' });
res.writeHead(302, { location: '/login?error=1' }); res.writeHead(302, { location: '/login?error=1' });
return res.end(); return res.end();
} }
const token = issueSession(user.id); loginOk(req, username);
const token = iam.issueSession(user.id);
iam.log(username, 'login.ok', username); iam.log(username, 'login.ok', username);
saveIam(iam); saveIam(iam);
const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/'; 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(); return res.end();
} }
if (req.method === 'POST' && url.pathname === '/logout') { if (req.method === 'POST' && url.pathname === '/logout') {
revokeSession(tokenFromReq(req)); iam.revokeSession(tokenFromReq(req));
saveIam(iam);
if ((req.headers['content-type'] || '').includes('json')) { if ((req.headers['content-type'] || '').includes('json')) {
res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() }); res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() });
return res.end(JSON.stringify({ ok: true })); 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) { 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; const domain = process.env.STAFF_COOKIE_DOMAIN;
if (domain) s += `; Domain=${domain}`; if (domain) s += `; Domain=${domain}`;
if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure'; if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure';
@ -46,3 +21,39 @@ export function tokenFromReq(req) {
const m = /(?:^|; )staff_session=([^;]+)/.exec(raw); const m = /(?:^|; )staff_session=([^;]+)/.exec(raw);
return m ? m[1] : ''; 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 = []; this.users = [];
/** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */ /** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */
this.audit = []; this.audit = [];
/** @type {Record<string,{userId:string,exp:number}>} */
this.sessions = {};
} }
seed() { seed() {
@ -131,14 +133,47 @@ export class StaffIam {
return user; 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() { dump() {
return { users: this.users, audit: this.audit }; this.pruneSessions();
return { users: this.users, audit: this.audit, sessions: this.sessions };
} }
load(raw) { load(raw) {
if (!raw || typeof raw !== 'object') return this; if (!raw || typeof raw !== 'object') return this;
this.users = Array.isArray(raw.users) ? raw.users : []; this.users = Array.isArray(raw.users) ? raw.users : [];
this.audit = Array.isArray(raw.audit) ? raw.audit : []; this.audit = Array.isArray(raw.audit) ? raw.audit : [];
this.sessions = raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {};
this.pruneSessions();
return this; return this;
} }
} }

View file

@ -12,6 +12,15 @@ test('owner expands to all permissions', () => {
assert.ok(ROLES.sales); 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', () => { test('cannot deactivate last owner; passwords hash', () => {
const iam = new StaffIam().seed(); const iam = new StaffIam().seed();
const owner = iam.findByUsername('admin'); 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 * @module lib/receiptPdf
*/ */
/**
* @param {string} s
* @returns {string}
*/
function pdfEscape(s) { function pdfEscape(s) {
return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); 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 * @param {object} receipt
* @returns {Buffer} * @returns {Buffer}
*/ */
export function buildReceiptPdf(receipt) { export function buildReceiptPdf(receipt) {
const lines = [ const lines = [];
'Verae Time — certified retrieval receipt', const add = (label, value) => {
`Type: ${receipt.type}`, lines.push({ kind: 'label', text: label });
`Job ID: ${receipt.jobId}`, wrap(value, 86).forEach((t) => lines.push({ kind: 'value', text: t }));
`SHA256: ${receipt.sha256 ?? ''}`, lines.push({ kind: 'gap' });
`Original timestamp: ${receipt.timestamp ?? ''}`, };
`Retrieved at: ${receipt.extraSeal?.retrievedAt ?? ''}`, add('Job ID', receipt.jobId);
`Tenant: ${receipt.extraSeal?.tenantId ?? ''}`, add('SHA-256', receipt.sha256 ?? '');
`Seal event: ${receipt.extraSeal?.event ?? ''}`, add('Original timestamp', receipt.timestamp ?? '');
`Certificate: ${String(receipt.certificate ?? '').slice(0, 80)}`, 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 const ops = [];
.map((line, i) => { ops.push('0.192 0.180 0.506 rg');
const y = 720 - i * 18; ops.push('0 742 612 50 re f');
return `BT /F1 11 Tf 50 ${y} Td (${pdfEscape(line)}) Tj ET`; ops.push('1 1 1 rg');
}) ops.push('BT /F1 16 Tf 36 760 Td (Verae Time) Tj ET');
.join('\n'); 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 = [ const objects = [
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj', '1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> 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`. - 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 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 ## 2026-09-11 — staff IAM
- New `verae-staff-iam` :3028 — users, roles, permissions, sessions, audit UI. - 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] Auth on CS/sales/accounting HTML via `verae-staff-session` (`STAFF_AUTH=1`).
- [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.
- [ ] 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 }) { export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission); 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(/\/$/, ''); const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) { if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); 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 }) { export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission); 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(/\/$/, ''); const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) { if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); 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) }); return json(200, { ok: true, role: 'zappier-customer-service', nats: Boolean(process.env.NATS_URL) });
} }
if (req.method === 'GET' && url.pathname === '/customers') { 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) }); return json(200, { customers: await listCustomers(EDGE, KEY) });
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);
@ -64,11 +65,13 @@ const server = http.createServer(async (req, res) => {
return json(out.status, out.body); return json(out.status, out.body);
} }
if (req.method === 'POST' && url.pathname === '/credits') { 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 = []; 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') || '{}');
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' }); if (nats) return json(200, { ...nats, source: 'nats' });
const r = await fetch(`${BOOKS}/adjust`, { const r = await fetch(`${BOOKS}/adjust`, {
method: 'POST', method: 'POST',

View file

@ -23,7 +23,7 @@ export async function iamCheck(req, permission) {
export async function denyOrRedirect(req, res, json, { permission, html }) { export async function denyOrRedirect(req, res, json, { permission, html }) {
const out = await iamCheck(req, permission); 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(/\/$/, ''); const login = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, '');
if (html) { if (html) {
res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); 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; req.staff = sess;
return next(); 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' }); res.status(403).json({ error: 'invalid or missing admin key' });
}; };
} }