Turn lab IAM on, move portal to access-web, harden sessions and receipts.
Some checks are pending
offline / test (push) Waiting to run
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:
parent
d299d245e8
commit
c32b65038a
20 changed files with 277 additions and 73 deletions
|
|
@ -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') + '/')}` });
|
||||||
|
|
|
||||||
|
|
@ -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`, {
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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": [],
|
||||||
|
|
|
||||||
|
|
@ -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') + '/')}` });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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') + '/')}` });
|
||||||
|
|
|
||||||
|
|
@ -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') + '/')}` });
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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') + '/')}` });
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue