From d299d245e8ee85cd8d25aa8321ead22880b49cbf Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 18:36:12 -0400 Subject: [PATCH] Add internal staff IAM: named users, roles, and management permissions. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verae-staff-iam (:3028) is the people directory — owner, billing-admin, cs, sales, accounting, operator, viewer — with scrypt passwords, sessions, and an audit log. Admin console login uses it when STAFF_IAM_URL is set and hides tabs the account cannot use. CS/sales/accounting/staff/fleet check permissions such as cs.credit and fleet.operate. Shared staff key remains only as a fallback when IAM is unset. --- packages/overview/02-modules-and-repos.md | 1 + packages/verae-access-staff/src/iam-gate.js | 35 ++++ packages/verae-access-staff/src/server.js | 13 +- .../verae-fleet/services/access-staff.json | 3 +- .../services/accounting-export.json | 3 +- .../services/customer-service.json | 3 +- .../verae-fleet/services/sales-pricing.json | 3 +- packages/verae-fleet/services/staff-iam.json | 20 ++ .../verae-fleet/services/zappier-edge.json | 1 + packages/verae-fleet/src/iam-gate.js | 35 ++++ packages/verae-fleet/src/server.js | 11 ++ packages/verae-staff-iam/NATS.md | 3 + packages/verae-staff-iam/README.md | 32 ++++ packages/verae-staff-iam/SUMMARY.md | 3 + packages/verae-staff-iam/package.json | 11 ++ packages/verae-staff-iam/public/app.html | 170 +++++++++++++++++ packages/verae-staff-iam/public/login.html | 42 ++++ packages/verae-staff-iam/src/client.js | 47 +++++ packages/verae-staff-iam/src/passwords.js | 26 +++ packages/verae-staff-iam/src/roles.js | 98 ++++++++++ packages/verae-staff-iam/src/server.js | 180 ++++++++++++++++++ packages/verae-staff-iam/src/sessions.js | 48 +++++ packages/verae-staff-iam/src/store.js | 166 ++++++++++++++++ packages/verae-staff-iam/test/health.test.js | 57 ++++++ packages/verae-staff-iam/test/roles.test.js | 22 +++ packages/verae-staff-session/src/server.js | 29 ++- packages/zapier-decisions/LOG.md | 7 + packages/zapier-decisions/TODO.md | 1 + .../zappier-accounting-export/src/iam-gate.js | 35 ++++ .../zappier-accounting-export/src/server.js | 14 +- .../zappier-customer-service/src/iam-gate.js | 35 ++++ .../zappier-customer-service/src/server.js | 14 +- .../zappier-sales-pricing/src/iam-gate.js | 35 ++++ packages/zappier-sales-pricing/src/server.js | 13 +- packages/zappier/admin/app.js | 46 ++++- packages/zappier/src/admin.ts | 59 +++++- packages/zappier/src/app.ts | 3 +- packages/zappier/src/staff-iam.ts | 61 ++++++ scripts/build-docs-site.py | 1 + scripts/gen-module-docs.py | 1 + scripts/push-module-repos.sh | 2 + 41 files changed, 1337 insertions(+), 52 deletions(-) create mode 100644 packages/verae-access-staff/src/iam-gate.js create mode 100644 packages/verae-fleet/services/staff-iam.json create mode 100644 packages/verae-fleet/src/iam-gate.js create mode 100644 packages/verae-staff-iam/NATS.md create mode 100644 packages/verae-staff-iam/README.md create mode 100644 packages/verae-staff-iam/SUMMARY.md create mode 100644 packages/verae-staff-iam/package.json create mode 100644 packages/verae-staff-iam/public/app.html create mode 100644 packages/verae-staff-iam/public/login.html create mode 100644 packages/verae-staff-iam/src/client.js create mode 100644 packages/verae-staff-iam/src/passwords.js create mode 100644 packages/verae-staff-iam/src/roles.js create mode 100644 packages/verae-staff-iam/src/server.js create mode 100644 packages/verae-staff-iam/src/sessions.js create mode 100644 packages/verae-staff-iam/src/store.js create mode 100644 packages/verae-staff-iam/test/health.test.js create mode 100644 packages/verae-staff-iam/test/roles.test.js create mode 100644 packages/zappier-accounting-export/src/iam-gate.js create mode 100644 packages/zappier-customer-service/src/iam-gate.js create mode 100644 packages/zappier-sales-pricing/src/iam-gate.js create mode 100644 packages/zappier/src/staff-iam.ts diff --git a/packages/overview/02-modules-and-repos.md b/packages/overview/02-modules-and-repos.md index 5becb76..184a80f 100644 --- a/packages/overview/02-modules-and-repos.md +++ b/packages/overview/02-modules-and-repos.md @@ -35,6 +35,7 @@ Each runtime piece is its **own git repo** on Forgejo (`git.georgelambert.org`, | **UI-Docs** | `packages/ui-docs` | Operator/staff/portal walkthrough, screenshots, review PDF | | **verae-staff-session** | `packages/verae-staff-session` | Shared staff cookie login for department HTML | | **verae-staff-ui** | `packages/verae-staff-ui` | Shared staff review HTML (CS + access-staff) | +| **verae-staff-iam** | `packages/verae-staff-iam` | Internal staff users, roles, permissions, audit | | **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` | | **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services | diff --git a/packages/verae-access-staff/src/iam-gate.js b/packages/verae-access-staff/src/iam-gate.js new file mode 100644 index 0000000..c5e6144 --- /dev/null +++ b/packages/verae-access-staff/src/iam-gate.js @@ -0,0 +1,35 @@ +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok) }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export async function denyOrRedirect(req, res, json, { permission, html }) { + const out = await iamCheck(req, permission); + if (out.ok) return true; + 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') + '/')}` }); + res.end(); + return false; + } + json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission }); + return false; +} diff --git a/packages/verae-access-staff/src/server.js b/packages/verae-access-staff/src/server.js index a517f1e..61967ce 100644 --- a/packages/verae-access-staff/src/server.js +++ b/packages/verae-access-staff/src/server.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { listCustomers, withCustomerName } from './names.js'; import { staffPageHtml } from './staff-page.js'; +import { denyOrRedirect } from './iam-gate.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); const PORT = Number(process.env.PORT || 3025); @@ -32,15 +33,7 @@ const server = http.createServer(async (req, res) => { }; try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { - if (process.env.STAFF_AUTH === '1') { - const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); - const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); - if (!chk || !chk.ok) { - res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); - res.end(); - return; - } - } + if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane', html: true }))) return; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end( await staffPageHtml( @@ -62,6 +55,7 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { + if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane' }))) return; const id = decodeURIComponent(review[1]); const gate = await check('verae.billing.statement.get', { principal: id }); if (!gate.allow) return json(403, gate); @@ -70,6 +64,7 @@ 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 chunks = []; for await (const c of req) chunks.push(c); const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); diff --git a/packages/verae-fleet/services/access-staff.json b/packages/verae-fleet/services/access-staff.json index 988b429..bef385e 100644 --- a/packages/verae-fleet/services/access-staff.json +++ b/packages/verae-fleet/services/access-staff.json @@ -16,7 +16,8 @@ "env": { "PORT": "3025", "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", + "STAFF_IAM_URL": "http://127.0.0.1:3028" }, "nats": { "in": [], "out": ["verae.access.authz.check"] } } diff --git a/packages/verae-fleet/services/accounting-export.json b/packages/verae-fleet/services/accounting-export.json index e24a5a5..a0b27ce 100644 --- a/packages/verae-fleet/services/accounting-export.json +++ b/packages/verae-fleet/services/accounting-export.json @@ -17,7 +17,8 @@ "PORT": "3013", "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "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", + "STAFF_IAM_URL": "http://127.0.0.1:3028" }, "nats": { "in": [], "out": ["verae.billing.statement.get"] } } diff --git a/packages/verae-fleet/services/customer-service.json b/packages/verae-fleet/services/customer-service.json index 6d39b76..8470c02 100644 --- a/packages/verae-fleet/services/customer-service.json +++ b/packages/verae-fleet/services/customer-service.json @@ -18,7 +18,8 @@ "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "NATS_URL": "nats://127.0.0.1:4222", - "AUTHZ_URL": "http://127.0.0.1:3020" + "AUTHZ_URL": "http://127.0.0.1:3020", + "STAFF_IAM_URL": "http://127.0.0.1:3028" }, "nats": { "in": [], diff --git a/packages/verae-fleet/services/sales-pricing.json b/packages/verae-fleet/services/sales-pricing.json index fbda599..d95744e 100644 --- a/packages/verae-fleet/services/sales-pricing.json +++ b/packages/verae-fleet/services/sales-pricing.json @@ -18,7 +18,8 @@ "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "NATS_URL": "nats://127.0.0.1:4222", - "AUTHZ_URL": "http://127.0.0.1:3020" + "AUTHZ_URL": "http://127.0.0.1:3020", + "STAFF_IAM_URL": "http://127.0.0.1:3028" }, "nats": { "in": [], "out": ["verae.billing.statement.get"] } } diff --git a/packages/verae-fleet/services/staff-iam.json b/packages/verae-fleet/services/staff-iam.json new file mode 100644 index 0000000..437b98a --- /dev/null +++ b/packages/verae-fleet/services/staff-iam.json @@ -0,0 +1,20 @@ +{ + "id": "staff-iam", + "title": "Internal staff IAM (users, roles, permissions)", + "kind": "http", + "package": "verae-staff-iam", + "role": "staff-iam", + "managed": true, + "runtime": "HTTP :3028", + "health": { "type": "http", "path": "/health", "timeoutMs": 2000 }, + "ports": { "healthBase": 3028 }, + "spawn": { + "cwd": "../verae-staff-iam", + "command": "node", + "args": ["src/server.js"] + }, + "env": { + "PORT": "3028" + }, + "nats": { "in": [], "out": [] } +} diff --git a/packages/verae-fleet/services/zappier-edge.json b/packages/verae-fleet/services/zappier-edge.json index 52ec636..3f9ed25 100644 --- a/packages/verae-fleet/services/zappier-edge.json +++ b/packages/verae-fleet/services/zappier-edge.json @@ -15,6 +15,7 @@ }, "env": { "PORT": "3000", + "STAFF_IAM_URL": "http://127.0.0.1:3028", "BIND": "127.0.0.1", "ZAPPIER_UPSTREAM": "http://127.0.0.1:3100", "CS_SERVICE_URL": "http://127.0.0.1:3011", diff --git a/packages/verae-fleet/src/iam-gate.js b/packages/verae-fleet/src/iam-gate.js new file mode 100644 index 0000000..c5e6144 --- /dev/null +++ b/packages/verae-fleet/src/iam-gate.js @@ -0,0 +1,35 @@ +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok) }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export async function denyOrRedirect(req, res, json, { permission, html }) { + const out = await iamCheck(req, permission); + if (out.ok) return true; + 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') + '/')}` }); + res.end(); + return false; + } + json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission }); + return false; +} diff --git a/packages/verae-fleet/src/server.js b/packages/verae-fleet/src/server.js index 67843b1..da2289b 100644 --- a/packages/verae-fleet/src/server.js +++ b/packages/verae-fleet/src/server.js @@ -10,6 +10,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { listServices } from './load.js'; import { ACTIONS, Simulator } from '../../verae-zapier-simulator/src/pipeline.js'; +import { denyOrRedirect } from './iam-gate.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); @@ -33,6 +34,16 @@ export function startControlServer(sup, mon) { if (req.method === 'GET' && url.pathname === '/health') { return json(res, 200, { ok: true, role: 'operator-console' }); } + const gated = + url.pathname === '/' || + url.pathname === '/index.html' || + (url.pathname.startsWith('/api/') && req.method !== 'GET'); + if (gated) { + const j = (code, obj) => json(res, code, obj); + if (!(await denyOrRedirect(req, res, j, { permission: 'fleet.operate', html: url.pathname === '/' || url.pathname === '/index.html' }))) { + return; + } + } if (req.method === 'GET' && url.pathname === '/api/sim/actions') { return json(res, 200, { actions: ACTIONS, state: sim.snapshot() }); } diff --git a/packages/verae-staff-iam/NATS.md b/packages/verae-staff-iam/NATS.md new file mode 100644 index 0000000..199aa61 --- /dev/null +++ b/packages/verae-staff-iam/NATS.md @@ -0,0 +1,3 @@ +# NATS + +HTTP only. Department doors call `GET /check?permission=`. Authz stays plane-based; IAM is the people hop in front of the staff plane. diff --git a/packages/verae-staff-iam/README.md b/packages/verae-staff-iam/README.md new file mode 100644 index 0000000..dd404cb --- /dev/null +++ b/packages/verae-staff-iam/README.md @@ -0,0 +1,32 @@ +# verae-staff-iam + +Named **internal staff** accounts with **roles and permissions**. Source of truth for who may use CS, sales, accounting, the staff plane, the operator console, and the billing admin console. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-iam + +Port **`:3028`**. UI: sign-in, people, roles, audit. + +## Seed lab users + +| Username | Password | Roles | +|----------|----------|--------| +| `admin` | `admin-dev-key` | owner (all) | +| `cs` | `cs-dev-key` | cs | +| `sales` | `sales-dev-key` | sales | +| `accounting` | `acct-dev-key` | accounting | +| `operator` | `fleet-dev-key` | operator | + +Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH`. + +## Wire other doors + +```bash +STAFF_IAM_URL=http://127.0.0.1:3028 +STAFF_AUTH=1 # department HTML still redirects if check fails +``` + +`GET /check?permission=cs.credit` — cookie or `Authorization: Bearer`. Cookie name remains `staff_session`. Multi-host: `STAFF_COOKIE_DOMAIN`. + +## Roles + +`owner`, `iam-admin`, `billing-admin`, `cs`, `sales`, `accounting`, `operator`, `viewer`. Permissions are listed on `/roles`. diff --git a/packages/verae-staff-iam/SUMMARY.md b/packages/verae-staff-iam/SUMMARY.md new file mode 100644 index 0000000..b07e966 --- /dev/null +++ b/packages/verae-staff-iam/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-staff-iam + +Internal staff directory: users, roles, permissions, sessions, audit. Not customer portal accounts. diff --git a/packages/verae-staff-iam/package.json b/packages/verae-staff-iam/package.json new file mode 100644 index 0000000..8283d8b --- /dev/null +++ b/packages/verae-staff-iam/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-staff-iam", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Internal staff user management: named accounts, roles, permissions, audit", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/packages/verae-staff-iam/public/app.html b/packages/verae-staff-iam/public/app.html new file mode 100644 index 0000000..46573e6 --- /dev/null +++ b/packages/verae-staff-iam/public/app.html @@ -0,0 +1,170 @@ + + + + + + Staff IAM + + + + +
+
+
internal staff · permissions
+

Staff IAM

+

+
+ +
+
+
+ + +
+ + + diff --git a/packages/verae-staff-iam/public/login.html b/packages/verae-staff-iam/public/login.html new file mode 100644 index 0000000..2616bee --- /dev/null +++ b/packages/verae-staff-iam/public/login.html @@ -0,0 +1,42 @@ + + + + + + Staff sign-in + + + + +
+
+

Staff sign-in

+

Named account. Roles decide CS, sales, accounting, fleet, and billing admin.

+ + + + + +

+ +
+
+ + + diff --git a/packages/verae-staff-iam/src/client.js b/packages/verae-staff-iam/src/client.js new file mode 100644 index 0000000..13d3b6c --- /dev/null +++ b/packages/verae-staff-iam/src/client.js @@ -0,0 +1,47 @@ +/** HTTP client used by department doors and the admin console. */ + +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok), skipped: false, legacy: true }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { + cookie: req.headers.cookie || '', + authorization: req.headers.authorization || '', + }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export function iamLoginUrl(next) { + const base = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, ''); + return `${base}/login?next=${encodeURIComponent(next)}`; +} + +export async function denyOrRedirect(req, res, { permission, html, json }) { + const out = await iamCheck(req, permission); + if (out.ok) return out; + if (html) { + res.writeHead(302, { location: iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`) }); + res.end(); + return out; + } + json(out.status === 403 ? 403 : 401, { + error: out.reason || 'unauthorized', + permission: permission || undefined, + }); + return out; +} diff --git a/packages/verae-staff-iam/src/passwords.js b/packages/verae-staff-iam/src/passwords.js new file mode 100644 index 0000000..47a3543 --- /dev/null +++ b/packages/verae-staff-iam/src/passwords.js @@ -0,0 +1,26 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +const N = 16384; +const R = 8; +const P = 1; +const KEY_LEN = 32; + +export function hashPassword(password) { + const salt = randomBytes(16); + const hash = scryptSync(String(password), salt, KEY_LEN, { N, r: R, p: P }); + return `scrypt:${N}:${R}:${P}:${salt.toString('base64')}:${hash.toString('base64')}`; +} + +export function verifyPassword(password, stored) { + const parts = String(stored || '').split(':'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + const [, n, r, p, saltB64, hashB64] = parts; + const expected = Buffer.from(hashB64, 'base64'); + if (!expected.length) return false; + const actual = scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, { + N: Number(n), + r: Number(r), + p: Number(p), + }); + return timingSafeEqual(actual, expected); +} diff --git a/packages/verae-staff-iam/src/roles.js b/packages/verae-staff-iam/src/roles.js new file mode 100644 index 0000000..fe821c6 --- /dev/null +++ b/packages/verae-staff-iam/src/roles.js @@ -0,0 +1,98 @@ +/** Internal staff roles → permissions. Default deny on unknown. */ + +export const PERMISSIONS = Object.freeze([ + 'iam.users.read', + 'iam.users.write', + 'admin.pricing', + 'admin.tiers', + 'admin.customers', + 'admin.statement', + 'admin.invoices', + 'admin.reports', + 'admin.system', + 'cs.review', + 'cs.credit', + 'sales.review', + 'sales.quote', + 'accounting.review', + 'accounting.export', + 'staff.plane', + 'fleet.operate', +]); + +export const ROLES = Object.freeze({ + owner: { + title: 'Owner', + permissions: ['*'], + }, + 'iam-admin': { + title: 'IAM admin', + permissions: ['iam.users.read', 'iam.users.write'], + }, + 'billing-admin': { + title: 'Billing admin', + permissions: [ + 'admin.pricing', + 'admin.tiers', + 'admin.customers', + 'admin.statement', + 'admin.invoices', + 'admin.reports', + 'admin.system', + ], + }, + cs: { + title: 'Customer service', + permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'], + }, + sales: { + title: 'Sales', + permissions: ['sales.review', 'sales.quote', 'admin.statement', 'staff.plane'], + }, + accounting: { + title: 'Accounting', + permissions: ['accounting.review', 'accounting.export', 'admin.statement', 'admin.invoices', 'staff.plane'], + }, + operator: { + title: 'Fleet operator', + permissions: ['fleet.operate'], + }, + viewer: { + title: 'Read-only staff', + permissions: ['cs.review', 'sales.review', 'accounting.review', 'admin.statement', 'admin.reports', 'admin.system'], + }, +}); + +export function expandRoles(roles) { + const set = new Set(); + for (const id of roles || []) { + const def = ROLES[id]; + if (!def) continue; + for (const p of def.permissions) { + if (p === '*') return ['*']; + set.add(p); + } + } + return [...set].sort(); +} + +export function allows(permissions, need) { + if (!need) return true; + const list = permissions || []; + if (list.includes('*')) return true; + return list.includes(need); +} + +export function publicUser(u) { + if (!u) return null; + const roles = [...(u.roles || [])]; + return { + id: u.id, + username: u.username, + name: u.name || u.username, + active: u.active !== false, + roles, + permissions: expandRoles(roles), + createdMs: u.createdMs, + }; +} diff --git a/packages/verae-staff-iam/src/server.js b/packages/verae-staff-iam/src/server.js new file mode 100644 index 0000000..4b1f622 --- /dev/null +++ b/packages/verae-staff-iam/src/server.js @@ -0,0 +1,180 @@ +#!/usr/bin/env node +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 { verifyPassword } from './passwords.js'; +import { loadIam, saveIam } from './store.js'; +import { + issueSession, + getSession, + revokeSession, + cookieHeader, + clearCookieHeader, + tokenFromReq, +} from './sessions.js'; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); +const PORT = Number(process.env.PORT || 3028); +const iam = loadIam(); +saveIam(iam); + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if ((req.headers['content-type'] || '').includes('json')) { + try { + resolve(JSON.parse(text || '{}')); + } catch { + resolve({}); + } + return; + } + resolve(Object.fromEntries(new URLSearchParams(text))); + }); + }); +} + +function actorOf(req) { + const tok = tokenFromReq(req); + const s = getSession(tok); + if (!s) return null; + const u = iam.findById(s.userId); + if (!u || !u.active) return null; + return publicUser(u); +} + +function requirePerm(req, res, json, perm) { + const me = actorOf(req); + if (!me) { + json(401, { ok: false, reason: 'not signed in' }); + return null; + } + if (!allows(me.permissions, perm)) { + iam.log(me.username, 'deny', perm, req.url); + saveIam(iam); + json(403, { ok: false, reason: 'missing permission', permission: perm, username: me.username }); + return null; + } + return me; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(obj)); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'verae-staff-iam', users: iam.users.length }); + } + if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) { + const me = actorOf(req); + const file = me ? 'app.html' : 'login.html'; + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + return res.end(fs.readFileSync(path.join(PUBLIC, file))); + } + if (req.method === 'GET' && url.pathname === '/roles') { + return json(200, { roles: ROLES, permissions: PERMISSIONS }); + } + if (req.method === 'POST' && url.pathname === '/login') { + const body = await readBody(req); + const username = String(body.username || '').trim(); + const password = String(body.password || ''); + const next = body.next || '/'; + const user = iam.findByUsername(username); + if (!user || !user.active || !verifyPassword(password, user.passwordHash)) { + 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); + iam.log(username, 'login.ok', username); + saveIam(iam); + const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/'; + if ((req.headers['content-type'] || '').includes('json')) { + res.writeHead(200, { + 'content-type': 'application/json', + 'set-cookie': cookieHeader(token), + }); + return res.end(JSON.stringify({ token, user: publicUser(user) })); + } + res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc }); + return res.end(); + } + if (req.method === 'POST' && url.pathname === '/logout') { + revokeSession(tokenFromReq(req)); + if ((req.headers['content-type'] || '').includes('json')) { + res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() }); + return res.end(JSON.stringify({ ok: true })); + } + res.writeHead(302, { 'set-cookie': clearCookieHeader(), location: '/login' }); + return res.end(); + } + if (req.method === 'GET' && url.pathname === '/check') { + const me = actorOf(req); + if (!me) return json(401, { ok: false, reason: 'not signed in' }); + const need = url.searchParams.get('permission'); + if (need && !allows(me.permissions, need)) { + iam.log(me.username, 'deny', need, 'check'); + saveIam(iam); + return json(403, { ok: false, reason: 'missing permission', permission: need, username: me.username }); + } + return json(200, { ok: true, user: me, permission: need || null }); + } + if (req.method === 'GET' && url.pathname === '/me') { + const me = actorOf(req); + if (!me) return json(401, { ok: false }); + return json(200, { user: me }); + } + if (req.method === 'GET' && url.pathname === '/users') { + const me = requirePerm(req, res, json, 'iam.users.read'); + if (!me) return; + return json(200, { users: iam.users.map(publicUser) }); + } + if (req.method === 'GET' && url.pathname === '/audit') { + const me = requirePerm(req, res, json, 'iam.users.read'); + if (!me) return; + return json(200, { audit: iam.audit.slice(0, 200) }); + } + if (req.method === 'POST' && url.pathname === '/users') { + const me = requirePerm(req, res, json, 'iam.users.write'); + if (!me) return; + const body = await readBody(req); + try { + const user = iam.create({ ...body, actor: me.username }); + saveIam(iam); + return json(201, publicUser(user)); + } catch (err) { + return json(err.status || 400, { error: err.message }); + } + } + const upd = url.pathname.match(/^\/users\/([^/]+)$/); + if (req.method === 'PUT' && upd) { + const me = requirePerm(req, res, json, 'iam.users.write'); + if (!me) return; + const body = await readBody(req); + try { + const user = iam.update(decodeURIComponent(upd[1]), body, me.username); + saveIam(iam); + return json(200, publicUser(user)); + } catch (err) { + return json(err.status || 400, { error: err.message }); + } + } + json(404, { error: 'not found' }); + } catch (err) { + json(500, { error: err.message }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`verae-staff-iam http://0.0.0.0:${PORT}/ users=${iam.users.length}\n`); +}); diff --git a/packages/verae-staff-iam/src/sessions.js b/packages/verae-staff-iam/src/sessions.js new file mode 100644 index 0000000..688cdd3 --- /dev/null +++ b/packages/verae-staff-iam/src/sessions.js @@ -0,0 +1,48 @@ +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 domain = process.env.STAFF_COOKIE_DOMAIN; + if (domain) s += `; Domain=${domain}`; + if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure'; + return s; +} + +export function clearCookieHeader() { + let s = 'staff_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; + const domain = process.env.STAFF_COOKIE_DOMAIN; + if (domain) s += `; Domain=${domain}`; + return s; +} + +export function tokenFromReq(req) { + const bearer = req.headers?.authorization; + if (bearer?.startsWith('Bearer ')) return bearer.slice(7); + const raw = req.headers?.cookie || ''; + const m = /(?:^|; )staff_session=([^;]+)/.exec(raw); + return m ? m[1] : ''; +} diff --git a/packages/verae-staff-iam/src/store.js b/packages/verae-staff-iam/src/store.js new file mode 100644 index 0000000..3b606c2 --- /dev/null +++ b/packages/verae-staff-iam/src/store.js @@ -0,0 +1,166 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { hashPassword } from './passwords.js'; +import { ROLES } from './roles.js'; + +export function storePath() { + return process.env.STAFF_IAM_PATH || path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'staff-iam.json'); +} + +export function newId() { + return `stu_${randomBytes(6).toString('hex')}`; +} + +export class StaffIam { + constructor() { + /** @type {Array<{id:string,username:string,name:string,passwordHash:string,roles:string[],active:boolean,createdMs:number}>} */ + this.users = []; + /** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */ + this.audit = []; + } + + seed() { + if (this.users.length) return this; + const seeds = [ + { + username: process.env.IAM_OWNER_USER || process.env.ADMIN_USER || 'admin', + password: process.env.IAM_OWNER_PASSWORD || process.env.ADMIN_KEY || 'admin-dev-key', + name: 'Owner', + roles: ['owner'], + }, + { + username: process.env.IAM_CS_USER || 'cs', + password: process.env.IAM_CS_PASSWORD || 'cs-dev-key', + name: 'Customer service', + roles: ['cs'], + }, + { + username: process.env.IAM_SALES_USER || 'sales', + password: process.env.IAM_SALES_PASSWORD || 'sales-dev-key', + name: 'Sales', + roles: ['sales'], + }, + { + username: process.env.IAM_ACCT_USER || 'accounting', + password: process.env.IAM_ACCT_PASSWORD || 'acct-dev-key', + name: 'Accounting', + roles: ['accounting'], + }, + { + username: process.env.IAM_OPS_USER || 'operator', + password: process.env.IAM_OPS_PASSWORD || 'fleet-dev-key', + name: 'Fleet operator', + roles: ['operator'], + }, + ]; + for (const s of seeds) { + this.users.push({ + id: newId(), + username: s.username, + name: s.name, + passwordHash: hashPassword(s.password), + roles: s.roles, + active: true, + createdMs: Date.now(), + }); + } + this.log('system', 'seed', null, `${this.users.length} users`); + return this; + } + + log(actor, action, target, detail) { + this.audit.unshift({ + t: new Date().toISOString(), + actor: actor || 'system', + action, + target: target || undefined, + detail: detail || undefined, + }); + this.audit = this.audit.slice(0, 400); + } + + findByUsername(username) { + return this.users.find((u) => u.username === username); + } + + findById(id) { + return this.users.find((u) => u.id === id); + } + + create({ username, password, name, roles, actor }) { + if (!/^[a-zA-Z0-9_.-]+$/.test(username || '')) throw Object.assign(new Error('bad username'), { status: 400 }); + if (!password || String(password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 }); + if (this.findByUsername(username)) throw Object.assign(new Error('username already exists'), { status: 409 }); + const cleanRoles = (roles || []).filter((r) => ROLES[r]); + const user = { + id: newId(), + username, + name: name || username, + passwordHash: hashPassword(password), + roles: cleanRoles.length ? cleanRoles : ['viewer'], + active: true, + createdMs: Date.now(), + }; + this.users.push(user); + this.log(actor, 'user.create', username, user.roles.join(',')); + return user; + } + + update(username, patch, actor) { + const user = this.findByUsername(username); + if (!user) throw Object.assign(new Error('not found'), { status: 404 }); + if (patch.name !== undefined) user.name = String(patch.name); + if (Array.isArray(patch.roles)) { + user.roles = patch.roles.filter((r) => ROLES[r]); + } + if (typeof patch.active === 'boolean') { + if (patch.active === false) { + const owners = this.users.filter((u) => u.active && u.roles.includes('owner')); + if (user.roles.includes('owner') && owners.length <= 1) { + throw Object.assign(new Error('cannot deactivate the last owner'), { status: 400 }); + } + } + user.active = patch.active; + } + if (patch.password) { + if (String(patch.password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 }); + user.passwordHash = hashPassword(patch.password); + } + this.log(actor, 'user.update', username, JSON.stringify({ roles: user.roles, active: user.active })); + return user; + } + + dump() { + return { users: this.users, audit: this.audit }; + } + + 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 : []; + return this; + } +} + +export function loadIam() { + const iam = new StaffIam(); + const p = storePath(); + if (fs.existsSync(p)) { + try { + iam.load(JSON.parse(fs.readFileSync(p, 'utf8'))); + } catch { + /* empty */ + } + } + if (!iam.users.length) iam.seed(); + return iam; +} + +export function saveIam(iam) { + const p = storePath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(iam.dump(), null, 2)); + fs.renameSync(tmp, p); +} diff --git a/packages/verae-staff-iam/test/health.test.js b/packages/verae-staff-iam/test/health.test.js new file mode 100644 index 0000000..b9ffb95 --- /dev/null +++ b/packages/verae-staff-iam/test/health.test.js @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +test('iam login, permission check, user CRUD', async () => { + const port = 18028; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-')); + const child = spawn(process.execPath, ['src/server.js'], { + cwd: root, + env: { ...process.env, PORT: String(port), STAFF_IAM_PATH: path.join(dir, 'iam.json') }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await new Promise((r) => setTimeout(r, 500)); + try { + const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json(); + assert.equal(h.role, 'verae-staff-iam'); + const login = await fetch(`http://127.0.0.1:${port}/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'admin-dev-key' }), + }); + assert.equal(login.status, 200); + const { token, user } = await login.json(); + assert.ok(user.permissions.includes('*')); + const hdr = { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; + const ok = await fetch(`http://127.0.0.1:${port}/check?permission=cs.credit`, { headers: hdr }); + assert.equal(ok.status, 200); + const csLogin = await fetch(`http://127.0.0.1:${port}/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'cs', password: 'cs-dev-key' }), + }); + const cs = await csLogin.json(); + const deny = await fetch(`http://127.0.0.1:${port}/check?permission=accounting.export`, { + headers: { authorization: `Bearer ${cs.token}` }, + }); + assert.equal(deny.status, 403); + const created = await fetch(`http://127.0.0.1:${port}/users`, { + method: 'POST', + headers: hdr, + body: JSON.stringify({ username: 'pat', password: 'pat-pass-99', name: 'Pat', roles: ['sales'] }), + }); + assert.equal(created.status, 201); + const body = await created.json(); + assert.deepEqual(body.roles, ['sales']); + const list = await (await fetch(`http://127.0.0.1:${port}/users`, { headers: hdr })).json(); + assert.ok(list.users.some((u) => u.username === 'pat')); + } finally { + child.kill('SIGTERM'); + } +}); diff --git a/packages/verae-staff-iam/test/roles.test.js b/packages/verae-staff-iam/test/roles.test.js new file mode 100644 index 0000000..e89bd41 --- /dev/null +++ b/packages/verae-staff-iam/test/roles.test.js @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { expandRoles, allows, ROLES } from '../src/roles.js'; +import { StaffIam } from '../src/store.js'; +import { verifyPassword } from '../src/passwords.js'; + +test('owner expands to all permissions', () => { + assert.deepEqual(expandRoles(['owner']), ['*']); + assert.equal(allows(['*'], 'cs.credit'), true); + assert.equal(allows(expandRoles(['cs']), 'cs.credit'), true); + assert.equal(allows(expandRoles(['cs']), 'accounting.export'), false); + assert.ok(ROLES.sales); +}); + +test('cannot deactivate last owner; passwords hash', () => { + const iam = new StaffIam().seed(); + const owner = iam.findByUsername('admin'); + assert.ok(verifyPassword(process.env.ADMIN_KEY || 'admin-dev-key', owner.passwordHash)); + assert.throws(() => iam.update('admin', { active: false }, 'admin'), /last owner/); + const cs = iam.create({ username: 'anna', password: 'anna-pass-1', name: 'Anna', roles: ['cs'], actor: 'admin' }); + assert.deepEqual(cs.roles, ['cs']); +}); diff --git a/packages/verae-staff-session/src/server.js b/packages/verae-staff-session/src/server.js index 525d3cc..809c6ce 100644 --- a/packages/verae-staff-session/src/server.js +++ b/packages/verae-staff-session/src/server.js @@ -50,8 +50,35 @@ const server = http.createServer(async (req, res) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; + const IAM = (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); + if (IAM && req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) { + const next = url.searchParams.get('next') || ''; + res.writeHead(302, { location: `${IAM}/login?next=${encodeURIComponent(next)}` }); + return res.end(); + } + if (IAM && (url.pathname === '/check' || url.pathname === '/login' || url.pathname === '/logout')) { + const target = `${IAM}${url.pathname}${url.search}`; + const r = await fetch(target, { + method: req.method, + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '', 'content-type': req.headers['content-type'] || '' }, + body: req.method === 'GET' ? undefined : await new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + }), + redirect: 'manual', + }); + const buf = Buffer.from(await r.arrayBuffer()); + const headers = { 'content-type': r.headers.get('content-type') || 'application/json' }; + const sc = r.headers.get('set-cookie'); + if (sc) headers['set-cookie'] = sc; + const loc = r.headers.get('location'); + if (loc) headers.location = loc; + res.writeHead(r.status, headers); + return res.end(buf); + } if (req.method === 'GET' && url.pathname === '/health') { - return json(200, { ok: true, role: 'verae-staff-session' }); + return json(200, { ok: true, role: 'verae-staff-session', iam: Boolean(IAM) }); } if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); diff --git a/packages/zapier-decisions/LOG.md b/packages/zapier-decisions/LOG.md index 49cd044..d714a0e 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 — staff IAM + +- New `verae-staff-iam` :3028 — users, roles, permissions, sessions, audit UI. +- Admin login uses IAM when `STAFF_IAM_URL` is set; tabs hide without permission. +- CS/sales/accounting/access-staff/fleet mutating APIs check `cs.credit`, `sales.quote`, `accounting.export`, `fleet.operate`, etc. +- Seed: admin/cs/sales/accounting/operator. Shared staff key is fallback only. + ## 2026-09-11 — last three UI leftovers - Swagger `/docs` stays stock; banner names it OpenAPI explorer. diff --git a/packages/zapier-decisions/TODO.md b/packages/zapier-decisions/TODO.md index a928b72..153f9e5 100644 --- a/packages/zapier-decisions/TODO.md +++ b/packages/zapier-decisions/TODO.md @@ -4,5 +4,6 @@ - [ ] NATS nkeys/mTLS on a real three-node cluster (accounts file is the lab stand-in). - [x] Exclusive JetStream consumer for `verae.zapier.jobs.events` on `verae-jobs-events` (`JOBS_EVENTS_EXCLUSIVE=1`; middleware skips the router). - [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). diff --git a/packages/zappier-accounting-export/src/iam-gate.js b/packages/zappier-accounting-export/src/iam-gate.js new file mode 100644 index 0000000..c5e6144 --- /dev/null +++ b/packages/zappier-accounting-export/src/iam-gate.js @@ -0,0 +1,35 @@ +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok) }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export async function denyOrRedirect(req, res, json, { permission, html }) { + const out = await iamCheck(req, permission); + if (out.ok) return true; + 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') + '/')}` }); + res.end(); + return false; + } + json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission }); + return false; +} diff --git a/packages/zappier-accounting-export/src/server.js b/packages/zappier-accounting-export/src/server.js index fbe02ff..aef0758 100644 --- a/packages/zappier-accounting-export/src/server.js +++ b/packages/zappier-accounting-export/src/server.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SUBJECTS, billingRequest } from './nats-billing.js'; import { listCustomers, withCustomerName } from './names.js'; +import { denyOrRedirect } from './iam-gate.js'; const PORT = Number(process.env.PORT || 3013); const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); @@ -27,15 +28,7 @@ const server = http.createServer(async (req, res) => { const json = (code, obj) => send(code, 'application/json', JSON.stringify(obj)); try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { - if (process.env.STAFF_AUTH === '1') { - const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); - const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); - if (!chk || !chk.ok) { - res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); - res.end(); - return; - } - } + if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review', html: true }))) return; return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html'))); } if (req.method === 'GET' && url.pathname === '/health') { @@ -46,6 +39,7 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { + if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review' }))) return; const id = decodeURIComponent(review[1]); const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id }); if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY)); @@ -58,10 +52,12 @@ const server = http.createServer(async (req, res) => { const period = url.searchParams.get('period'); const q = period ? `?period=${encodeURIComponent(period)}` : ''; if (req.method === 'GET' && url.pathname === '/export/quickbooks.iif') { + if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.export' }))) return; const r = await edge(`/admin/api/exports/quickbooks.iif${q}`); return send(r.status, 'text/plain', await r.text()); } if (req.method === 'GET' && url.pathname === '/export/accounting.csv') { + if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.export' }))) return; const r = await edge(`/admin/api/exports/accounting.csv${q}`); return send(r.status, 'text/csv', await r.text()); } diff --git a/packages/zappier-customer-service/src/iam-gate.js b/packages/zappier-customer-service/src/iam-gate.js new file mode 100644 index 0000000..c5e6144 --- /dev/null +++ b/packages/zappier-customer-service/src/iam-gate.js @@ -0,0 +1,35 @@ +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok) }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export async function denyOrRedirect(req, res, json, { permission, html }) { + const out = await iamCheck(req, permission); + if (out.ok) return true; + 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') + '/')}` }); + res.end(); + return false; + } + json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission }); + return false; +} diff --git a/packages/zappier-customer-service/src/server.js b/packages/zappier-customer-service/src/server.js index 8f069fa..fef39ee 100644 --- a/packages/zappier-customer-service/src/server.js +++ b/packages/zappier-customer-service/src/server.js @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; import { SUBJECTS, billingRequest } from './nats-billing.js'; import { listCustomers, withCustomerName } from './names.js'; import { staffPageHtml } from './staff-page.js'; +import { denyOrRedirect } from './iam-gate.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); @@ -34,16 +35,7 @@ const server = http.createServer(async (req, res) => { }; try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { - if (process.env.STAFF_AUTH === '1') { - const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); - const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); - if (!chk || !chk.ok) { - const next = `http://${req.headers.host || '127.0.0.1'}/`; - res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent(next)}` }); - res.end(); - return; - } - } + if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review', html: true }))) return; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end( await staffPageHtml( @@ -65,12 +57,14 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { + if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return; const id = decodeURIComponent(review[1]); const out = await statement(id); out.body = await withCustomerName(out.body, id, EDGE, KEY); return json(out.status, out.body); } if (req.method === 'POST' && url.pathname === '/credits') { + if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return; const chunks = []; for await (const c of req) chunks.push(c); const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); diff --git a/packages/zappier-sales-pricing/src/iam-gate.js b/packages/zappier-sales-pricing/src/iam-gate.js new file mode 100644 index 0000000..c5e6144 --- /dev/null +++ b/packages/zappier-sales-pricing/src/iam-gate.js @@ -0,0 +1,35 @@ +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok) }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { cookie: req.headers.cookie || '', authorization: req.headers.authorization || '' }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export async function denyOrRedirect(req, res, json, { permission, html }) { + const out = await iamCheck(req, permission); + if (out.ok) return true; + 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') + '/')}` }); + res.end(); + return false; + } + json(out.status === 403 ? 403 : 401, { error: out.reason || 'unauthorized', permission }); + return false; +} diff --git a/packages/zappier-sales-pricing/src/server.js b/packages/zappier-sales-pricing/src/server.js index e36fa6c..ff85574 100644 --- a/packages/zappier-sales-pricing/src/server.js +++ b/packages/zappier-sales-pricing/src/server.js @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { SUBJECTS, billingRequest } from './nats-billing.js'; import { listCustomers, withCustomerName } from './names.js'; +import { denyOrRedirect } from './iam-gate.js'; const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); @@ -37,15 +38,7 @@ const server = http.createServer(async (req, res) => { }; try { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { - if (process.env.STAFF_AUTH === '1') { - const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); - const chk = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); - if (!chk || !chk.ok) { - res.writeHead(302, { location: `${login}/login?next=${encodeURIComponent('http://' + (req.headers.host || '127.0.0.1') + '/')}` }); - res.end(); - return; - } - } + if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review', html: true }))) return; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(fs.readFileSync(path.join(PUBLIC, 'index.html'))); return; @@ -58,6 +51,7 @@ const server = http.createServer(async (req, res) => { } const review = url.pathname.match(/^\/review\/([^/]+)$/); if (req.method === 'GET' && review) { + if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review' }))) return; const id = decodeURIComponent(review[1]); const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id }); if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY)); @@ -68,6 +62,7 @@ const server = http.createServer(async (req, res) => { } const quote = url.pathname.match(/^\/quotes\/([^/]+)$/); if (req.method === 'GET' && quote) { + if (!(await denyOrRedirect(req, res, json, { permission: 'sales.quote' }))) return; const forwarded = await edge(`/admin/api/sales/quote/${quote[1]}`); return json(forwarded.status, forwarded.body); } diff --git a/packages/zappier/admin/app.js b/packages/zappier/admin/app.js index 000aff0..1e024ae 100644 --- a/packages/zappier/admin/app.js +++ b/packages/zappier/admin/app.js @@ -92,11 +92,41 @@ function currentPeriod() { return new Date().toISOString().slice(0, 7); } +const TAB_PERM = { + endpoints: 'admin.pricing', + tiers: 'admin.tiers', + customers: 'admin.customers', + statement: 'admin.statement', + invoices: 'admin.invoices', + reports: 'admin.reports', + system: 'admin.system', + users: 'iam.users.read', +}; +function can(perm) { + const p = state.me?.permissions || ['*']; + return p.includes('*') || p.includes(perm); +} +function applyNav() { + document.querySelectorAll('aside nav button').forEach((btn) => { + const need = TAB_PERM[btn.dataset.tab]; + btn.style.display = !need || can(need) ? '' : 'none'; + }); +} + async function load() { + state.me = await api('/me').catch(() => ({ permissions: ['*'] })); + applyNav(); + if (!can('admin.pricing') && !can('admin.tiers') && !can('admin.customers')) { + if (state.me?.iamUrl) { + document.getElementById('endpoints').innerHTML = + `

Staff IAM

This console is for billing admins. Manage people at ${state.me.iamUrl}.

`; + } + } + if (!can('admin.pricing') && !can('admin.tiers')) return; state.pricing = await api('/pricing'); - state.customers = (await api('/customers')).customers; - state.invoices = (await api('/invoices')).invoices; - state.users = (await api('/users')).users; + state.customers = can('admin.customers') ? (await api('/customers')).customers : []; + state.invoices = can('admin.invoices') ? (await api('/invoices')).invoices : []; + state.users = can('iam.users.read') || !state.me?.iam ? (await api('/users')).users : []; renderEndpoints(); renderTiers(); renderCustomers(); @@ -703,6 +733,16 @@ async function loadSystem() { /* ---------------- admin users ---------------- */ function renderUsers() { + if (state.me?.iam) { + document.getElementById('users').innerHTML = ` +

Staff users

+

Named internal accounts and roles live in Staff IAM — not the billing-console seed table.

+
+

Open ${state.me.iamUrl || 'http://127.0.0.1:3028/'}

+

Roles: owner, iam-admin, billing-admin, cs, sales, accounting, operator, viewer.

+
`; + return; + } const rows = state.users .slice() .sort((a, b) => a.username.localeCompare(b.username)) diff --git a/packages/zappier/src/admin.ts b/packages/zappier/src/admin.ts index f0db185..9f9ff7a 100644 --- a/packages/zappier/src/admin.ts +++ b/packages/zappier/src/admin.ts @@ -15,26 +15,43 @@ import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-e import { composeStatement } from './statement'; import { BILLING_SUBJECTS, natsPublish } from './billing-nats'; import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger'; +import { allows, iamLogin, iamUrl, permForAdminPath, StaffSession } from './staff-iam'; // Issued login tokens (in-memory; a restart simply requires logging in again). -const sessions = new Map(); +const sessions = new Map(); + +declare module 'express-serve-static-core' { + interface Request { + staff?: StaffSession; + } +} export function adminLoginRouter(users: AdminUserRepo): Router { const router = Router(); - router.post('/login', (req, res) => { + router.post('/login', async (req, res) => { const { username, password } = req.body ?? {}; if (typeof username !== 'string' || typeof password !== 'string') { res.status(401).json({ error: 'invalid username or password' }); return; } + if (iamUrl()) { + const via = await iamLogin(username, password); + if (!via) { + res.status(401).json({ error: 'invalid username or password' }); + return; + } + sessions.set(via.token, via.user); + res.json({ token: via.token, user: via.user }); + return; + } const user = users.findByUsername(username); if (!user || !user.active || !verifyPassword(password, user.passwordHash)) { res.status(401).json({ error: 'invalid username or password' }); return; } const token = randomBytes(24).toString('hex'); - sessions.set(token, Date.now()); - res.json({ token }); + sessions.set(token, { username, permissions: ['*'], t: Date.now() }); + res.json({ token, user: { username, permissions: ['*'] } }); }); return router; } @@ -42,14 +59,33 @@ export function adminLoginRouter(users: AdminUserRepo): Router { export function adminAuth(): RequestHandler { return (req, res, next) => { const expected = process.env.ADMIN_KEY ?? 'admin-dev-key'; - if (req.header('x-admin-key') === expected) return next(); + if (req.header('x-admin-key') === expected) { + req.staff = { username: 'x-admin-key', permissions: ['*'], t: Date.now() }; + return next(); + } const bearer = req.header('authorization'); const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined; - if (token && sessions.has(token)) return next(); + const sess = token ? sessions.get(token) : undefined; + if (sess) { + req.staff = sess; + return next(); + } res.status(403).json({ error: 'invalid or missing admin key' }); }; } +export function adminPerms(): RequestHandler { + return (req, res, next) => { + if (!iamUrl()) return next(); + const need = permForAdminPath(req.method, req.path); + if (!allows(req.staff?.permissions, need)) { + res.status(403).json({ error: 'forbidden', permission: need }); + return; + } + next(); + }; +} + function isValidRule(rule: unknown): rule is PriceRule { if (!rule || typeof rule !== 'object') return false; const r = rule as Record; @@ -148,6 +184,17 @@ export function adminRouter( ): Router { const router = Router(); + router.get('/me', (req, res) => { + res.json({ + username: req.staff?.username, + name: req.staff?.name, + roles: req.staff?.roles || [], + permissions: req.staff?.permissions || ['*'], + iam: Boolean(iamUrl()), + iamUrl: iamUrl() || undefined, + }); + }); + router.get('/pricing', (req, res) => { res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() }); }); diff --git a/packages/zappier/src/app.ts b/packages/zappier/src/app.ts index a8c2c8a..140a3e0 100644 --- a/packages/zappier/src/app.ts +++ b/packages/zappier/src/app.ts @@ -19,7 +19,7 @@ import { PricingStore, } from './pricing'; import { InMemoryUsageRepo, UsageRepo } from './usage'; -import { adminAuth, adminLoginRouter, adminRouter } from './admin'; +import { adminAuth, adminLoginRouter, adminPerms, adminRouter } from './admin'; import { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users'; import { InMemorySessionRepo, SessionRepo } from './accounts'; import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing'; @@ -135,6 +135,7 @@ export function buildApp(deps: AppDeps = {}): { '/admin/api', adminLoginRouter(adminUsers), adminAuth(), + adminPerms(), adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits), ); app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin'))); diff --git a/packages/zappier/src/staff-iam.ts b/packages/zappier/src/staff-iam.ts new file mode 100644 index 0000000..71cf921 --- /dev/null +++ b/packages/zappier/src/staff-iam.ts @@ -0,0 +1,61 @@ +export function iamUrl(): string { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export type StaffSession = { + username: string; + name?: string; + roles?: string[]; + permissions: string[]; + t: number; +}; + +export function allows(permissions: string[] | undefined, need: string | undefined): boolean { + if (!need) return true; + const list = permissions || []; + return list.includes('*') || list.includes(need); +} + +export function permForAdminPath(method: string, p: string): string | undefined { + if (p === '/me' || p === '/login') return undefined; + if (p === '/pricing' || p.startsWith('/pricing')) return 'admin.pricing'; + if (p.startsWith('/endpoints')) return 'admin.pricing'; + if (p.startsWith('/tiers')) return 'admin.tiers'; + if (p.startsWith('/customers') || p.startsWith('/credits')) return 'admin.customers'; + if (p.startsWith('/statement')) return 'admin.statement'; + if (p.startsWith('/invoices') || p.startsWith('/exports')) return 'admin.invoices'; + if (p.startsWith('/reports')) return 'admin.reports'; + if (p.startsWith('/zapier')) return 'admin.system'; + if (p.startsWith('/users') || p.startsWith('/staff')) { + return method === 'GET' ? 'iam.users.read' : 'iam.users.write'; + } + return undefined; +} + +export async function iamLogin( + username: string, + password: string, +): Promise<{ token: string; user: StaffSession } | null> { + const base = iamUrl(); + if (!base) return null; + const r = await fetch(`${base}/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!r.ok) return null; + const data = (await r.json()) as { + token: string; + user: { username: string; name?: string; roles?: string[]; permissions: string[] }; + }; + return { + token: data.token, + user: { + username: data.user.username, + name: data.user.name, + roles: data.user.roles, + permissions: data.user.permissions || [], + t: Date.now(), + }, + }; +} diff --git a/scripts/build-docs-site.py b/scripts/build-docs-site.py index 0358140..95eff37 100755 --- a/scripts/build-docs-site.py +++ b/scripts/build-docs-site.py @@ -367,6 +367,7 @@ def main() -> None: "ui-docs", "verae-staff-session", "verae-staff-ui", + "verae-staff-iam", ): pkg_root = ROOT / "packages" / pkg if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}: diff --git a/scripts/gen-module-docs.py b/scripts/gen-module-docs.py index 3992a8f..b522e06 100644 --- a/scripts/gen-module-docs.py +++ b/scripts/gen-module-docs.py @@ -70,6 +70,7 @@ REPO_READMES = [ "ui-docs", "verae-staff-session", "verae-staff-ui", + "verae-staff-iam", ] SKIP_PARTS = {"test", "tests", "node_modules", "dist"} diff --git a/scripts/push-module-repos.sh b/scripts/push-module-repos.sh index 19d3ca8..2a49f9c 100755 --- a/scripts/push-module-repos.sh +++ b/scripts/push-module-repos.sh @@ -67,6 +67,7 @@ create zapier-decisions "Architecture decisions and action log" create UI-Docs "UI walkthrough, screenshots, and review PDF" create verae-staff-session "Shared staff cookie login for department HTML" create verae-staff-ui "Shared staff review HTML template" +create verae-staff-iam "Internal staff users, roles, and permissions" push_dir "$ROOT/packages/zappier" zappier-edge push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware @@ -100,5 +101,6 @@ push_dir "$ROOT/packages/zapier-decisions" zapier-decisions push_dir "$ROOT/packages/ui-docs" UI-Docs push_dir "$ROOT/packages/verae-staff-session" verae-staff-session push_dir "$ROOT/packages/verae-staff-ui" verae-staff-ui +push_dir "$ROOT/packages/verae-staff-iam" verae-staff-iam echo ALL_MODULE_REPOS_PUSHED