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 }) {
|
||||
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') + '/')}` });
|
||||
|
|
|
|||
|
|
@ -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`, {
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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') + '/')}` });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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') + '/')}` });
|
||||
|
|
|
|||
|
|
@ -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') + '/')}` });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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') + '/')}` });
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue