diff --git a/packages/verae-access-staff/src/iam-gate.js b/packages/verae-access-staff/src/iam-gate.js index c5e6144..e443b55 100644 --- a/packages/verae-access-staff/src/iam-gate.js +++ b/packages/verae-access-staff/src/iam-gate.js @@ -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') + '/')}` }); diff --git a/packages/verae-access-staff/src/server.js b/packages/verae-access-staff/src/server.js index 61967ce..7a0b75e 100644 --- a/packages/verae-access-staff/src/server.js +++ b/packages/verae-access-staff/src/server.js @@ -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`, { diff --git a/packages/verae-access-web/README.md b/packages/verae-access-web/README.md index 5d3414e..4afa551 100644 --- a/packages/verae-access-web/README.md +++ b/packages/verae-access-web/README.md @@ -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`. diff --git a/packages/verae-access-web/src/server.js b/packages/verae-access-web/src/server.js index c5351df..7802511 100644 --- a/packages/verae-access-web/src/server.js +++ b/packages/verae-access-web/src/server.js @@ -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`); }); diff --git a/packages/verae-access-web/test/health.test.js b/packages/verae-access-web/test/health.test.js index fbbdf18..0bd85cb 100644 --- a/packages/verae-access-web/test/health.test.js +++ b/packages/verae-access-web/test/health.test.js @@ -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'); diff --git a/packages/verae-fleet/services/access-web.json b/packages/verae-fleet/services/access-web.json index 65481a1..213e80d 100644 --- a/packages/verae-fleet/services/access-web.json +++ b/packages/verae-fleet/services/access-web.json @@ -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": [], diff --git a/packages/verae-fleet/src/iam-gate.js b/packages/verae-fleet/src/iam-gate.js index c5e6144..e443b55 100644 --- a/packages/verae-fleet/src/iam-gate.js +++ b/packages/verae-fleet/src/iam-gate.js @@ -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') + '/')}` }); diff --git a/packages/verae-staff-iam/README.md b/packages/verae-staff-iam/README.md index dd404cb..bf5db20 100644 --- a/packages/verae-staff-iam/README.md +++ b/packages/verae-staff-iam/README.md @@ -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 diff --git a/packages/verae-staff-iam/src/server.js b/packages/verae-staff-iam/src/server.js index 4b1f622..75d1cbe 100644 --- a/packages/verae-staff-iam/src/server.js +++ b/packages/verae-staff-iam/src/server.js @@ -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 })); diff --git a/packages/verae-staff-iam/src/sessions.js b/packages/verae-staff-iam/src/sessions.js index 688cdd3..0ab0d77 100644 --- a/packages/verae-staff-iam/src/sessions.js +++ b/packages/verae-staff-iam/src/sessions.js @@ -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)); +} diff --git a/packages/verae-staff-iam/src/store.js b/packages/verae-staff-iam/src/store.js index 3b606c2..0d12560 100644 --- a/packages/verae-staff-iam/src/store.js +++ b/packages/verae-staff-iam/src/store.js @@ -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} */ + 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; } } diff --git a/packages/verae-staff-iam/test/roles.test.js b/packages/verae-staff-iam/test/roles.test.js index e89bd41..8f9713c 100644 --- a/packages/verae-staff-iam/test/roles.test.js +++ b/packages/verae-staff-iam/test/roles.test.js @@ -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'); diff --git a/packages/verae-zapier-middleware/src/lib/receiptPdf.js b/packages/verae-zapier-middleware/src/lib/receiptPdf.js index 5a64c7b..b3e63d2 100644 --- a/packages/verae-zapier-middleware/src/lib/receiptPdf.js +++ b/packages/verae-zapier-middleware/src/lib/receiptPdf.js @@ -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', diff --git a/packages/zapier-decisions/LOG.md b/packages/zapier-decisions/LOG.md index d714a0e..f8fb409 100644 --- a/packages/zapier-decisions/LOG.md +++ b/packages/zapier-decisions/LOG.md @@ -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. diff --git a/packages/zapier-decisions/TODO.md b/packages/zapier-decisions/TODO.md index 153f9e5..a1ff100 100644 --- a/packages/zapier-decisions/TODO.md +++ b/packages/zapier-decisions/TODO.md @@ -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). diff --git a/packages/zappier-accounting-export/src/iam-gate.js b/packages/zappier-accounting-export/src/iam-gate.js index c5e6144..e443b55 100644 --- a/packages/zappier-accounting-export/src/iam-gate.js +++ b/packages/zappier-accounting-export/src/iam-gate.js @@ -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') + '/')}` }); diff --git a/packages/zappier-customer-service/src/iam-gate.js b/packages/zappier-customer-service/src/iam-gate.js index c5e6144..e443b55 100644 --- a/packages/zappier-customer-service/src/iam-gate.js +++ b/packages/zappier-customer-service/src/iam-gate.js @@ -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') + '/')}` }); diff --git a/packages/zappier-customer-service/src/server.js b/packages/zappier-customer-service/src/server.js index fef39ee..8ec9e26 100644 --- a/packages/zappier-customer-service/src/server.js +++ b/packages/zappier-customer-service/src/server.js @@ -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', diff --git a/packages/zappier-sales-pricing/src/iam-gate.js b/packages/zappier-sales-pricing/src/iam-gate.js index c5e6144..e443b55 100644 --- a/packages/zappier-sales-pricing/src/iam-gate.js +++ b/packages/zappier-sales-pricing/src/iam-gate.js @@ -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') + '/')}` }); diff --git a/packages/zappier/src/admin.ts b/packages/zappier/src/admin.ts index 9f9ff7a..a6e5d82 100644 --- a/packages/zappier/src/admin.ts +++ b/packages/zappier/src/admin.ts @@ -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' }); }; }