Add internal staff IAM: named users, roles, and management permissions.
Some checks are pending
offline / test (push) Waiting to run

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.
This commit is contained in:
George Lambert 2026-09-11 18:36:12 -04:00
parent 2740d51446
commit d299d245e8
41 changed files with 1337 additions and 52 deletions

View file

@ -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 | | **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-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-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` | | **zapier-docs-master** | `packages/docs-master` | Per-module `SUMMARY.md` + `NATS.md` |
| **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services | | **verae-ops** | `packages/verae-ops` | Docker, Proxmox, VMs, dedicated hardware, linking services |

View file

@ -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;
}

View file

@ -6,6 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { listCustomers, withCustomerName } from './names.js'; import { listCustomers, withCustomerName } from './names.js';
import { staffPageHtml } from './staff-page.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 PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
const PORT = Number(process.env.PORT || 3025); const PORT = Number(process.env.PORT || 3025);
@ -32,15 +33,7 @@ const server = http.createServer(async (req, res) => {
}; };
try { try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
if (process.env.STAFF_AUTH === '1') { if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane', html: true }))) return;
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;
}
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end( res.end(
await staffPageHtml( await staffPageHtml(
@ -62,6 +55,7 @@ const server = http.createServer(async (req, res) => {
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) { if (req.method === 'GET' && review) {
if (!(await denyOrRedirect(req, res, json, { permission: 'staff.plane' }))) return;
const id = decodeURIComponent(review[1]); const id = decodeURIComponent(review[1]);
const gate = await check('verae.billing.statement.get', { principal: id }); const gate = await check('verae.billing.statement.get', { principal: id });
if (!gate.allow) return json(403, gate); if (!gate.allow) return json(403, gate);
@ -70,6 +64,7 @@ const server = http.createServer(async (req, res) => {
return json(r.status, body); return json(r.status, body);
} }
if (req.method === 'POST' && url.pathname === '/credits') { if (req.method === 'POST' && url.pathname === '/credits') {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
const chunks = []; const chunks = [];
for await (const c of req) chunks.push(c); for await (const c of req) chunks.push(c);
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');

View file

@ -16,7 +16,8 @@
"env": { "env": {
"PORT": "3025", "PORT": "3025",
"AUTHZ_URL": "http://127.0.0.1:3020", "AUTHZ_URL": "http://127.0.0.1:3020",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010" "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"STAFF_IAM_URL": "http://127.0.0.1:3028"
}, },
"nats": { "in": [], "out": ["verae.access.authz.check"] } "nats": { "in": [], "out": ["verae.access.authz.check"] }
} }

View file

@ -17,7 +17,8 @@
"PORT": "3013", "PORT": "3013",
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222" "NATS_URL": "nats://127.0.0.1:4222",
"STAFF_IAM_URL": "http://127.0.0.1:3028"
}, },
"nats": { "in": [], "out": ["verae.billing.statement.get"] } "nats": { "in": [], "out": ["verae.billing.statement.get"] }
} }

View file

@ -18,7 +18,8 @@
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222", "NATS_URL": "nats://127.0.0.1:4222",
"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": { "nats": {
"in": [], "in": [],

View file

@ -18,7 +18,8 @@
"ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000", "ZAPPIER_ADMIN_URL": "http://127.0.0.1:3000",
"ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010", "ACCOUNT_BALANCE_URL": "http://127.0.0.1:3010",
"NATS_URL": "nats://127.0.0.1:4222", "NATS_URL": "nats://127.0.0.1:4222",
"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"] } "nats": { "in": [], "out": ["verae.billing.statement.get"] }
} }

View file

@ -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": [] }
}

View file

@ -15,6 +15,7 @@
}, },
"env": { "env": {
"PORT": "3000", "PORT": "3000",
"STAFF_IAM_URL": "http://127.0.0.1:3028",
"BIND": "127.0.0.1", "BIND": "127.0.0.1",
"ZAPPIER_UPSTREAM": "http://127.0.0.1:3100", "ZAPPIER_UPSTREAM": "http://127.0.0.1:3100",
"CS_SERVICE_URL": "http://127.0.0.1:3011", "CS_SERVICE_URL": "http://127.0.0.1:3011",

View file

@ -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;
}

View file

@ -10,6 +10,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { listServices } from './load.js'; import { listServices } from './load.js';
import { ACTIONS, Simulator } from '../../verae-zapier-simulator/src/pipeline.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'); 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') { if (req.method === 'GET' && url.pathname === '/health') {
return json(res, 200, { ok: true, role: 'operator-console' }); 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') { if (req.method === 'GET' && url.pathname === '/api/sim/actions') {
return json(res, 200, { actions: ACTIONS, state: sim.snapshot() }); return json(res, 200, { actions: ACTIONS, state: sim.snapshot() });
} }

View file

@ -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.

View file

@ -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`.

View file

@ -0,0 +1,3 @@
# verae-staff-iam
Internal staff directory: users, roles, permissions, sessions, audit. Not customer portal accounts.

View file

@ -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"
}
}

View file

@ -0,0 +1,170 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Staff IAM</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root { --bg:#f4f5fb; --panel:#fff; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; --accent:#4f46e5; --ok:#047857; --danger:#dc2626; --radius:12px; --shadow:0 1px 2px rgba(23,26,38,.05), 0 8px 24px rgba(23,26,38,.06); }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; background:var(--bg); color:var(--ink); }
header.app { background:linear-gradient(160deg,#312e81 0%,#4f46e5 60%,#7c74f0 100%); color:#eef0fe; padding:1.1rem 1.4rem; display:flex; justify-content:space-between; align-items:flex-end; gap:1rem; flex-wrap:wrap; }
.kicker { letter-spacing:.12em; text-transform:uppercase; font:700 10px system-ui; opacity:.75; }
header.app h1 { margin:.2rem 0 0; font-size:1.2rem; }
nav button { border:0; background:transparent; color:#c7c9ff; min-height:40px; padding:.55rem 1rem; border-radius:10px 10px 0 0; font:650 14px system-ui; cursor:pointer; }
nav button.on { background:var(--bg); color:var(--accent); }
main { max-width:1100px; margin:0 auto; padding:1.25rem 1.25rem 3rem; }
.card { background:#fff; border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:1.1rem 1.25rem; margin-bottom:1rem; }
.roles { display:flex; flex-wrap:wrap; gap:.35rem; }
.roles label { display:inline-flex; gap:.25rem; align-items:center; background:#eef0fe; border-radius:999px; padding:.2rem .55rem; font:650 11px system-ui; cursor:pointer; }
.pill { font:700 10px system-ui; letter-spacing:.05em; text-transform:uppercase; padding:.12rem .4rem; border-radius:999px; background:#eef0fe; color:var(--accent); }
.pill.off { background:#f1f5f9; color:#475569; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:.72rem; text-transform:uppercase; letter-spacing:.04em; color:var(--muted); border-bottom:1px solid var(--line); padding:.4rem .5rem; }
td { border-bottom:1px solid var(--line); padding:.5rem .5rem; vertical-align:top; }
input, select { padding:.4rem .55rem; border:1px solid var(--line); border-radius:8px; }
button.btn { border:0; border-radius:8px; padding:.42rem .85rem; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
button.ghost { background:#fff; color:var(--ink); border:1px solid var(--line); }
.muted { color:var(--muted); font-size:12px; }
.row { display:flex; flex-wrap:wrap; gap:.6rem; align-items:end; }
</style>
</head>
<body>
<header class="app">
<div>
<div class="kicker">internal staff · permissions</div>
<h1>Staff IAM</h1>
<p class="muted" id="who" style="color:#e4e7ff;opacity:.9"></p>
</div>
<nav>
<button class="on" data-tab="users">People</button>
<button data-tab="audit">Audit</button>
<button data-tab="roles">Roles</button>
</nav>
</header>
<main>
<section id="users" class="panel"></section>
<section id="audit" class="panel" hidden></section>
<section id="roles" class="panel" hidden></section>
</main>
<script>
const $ = (id) => document.getElementById(id);
async function j(url, opts) {
const r = await fetch(url, { credentials: 'same-origin', ...opts, headers: { 'content-type': 'application/json', ...(opts && opts.headers) } });
const body = await r.json().catch(() => ({}));
if (r.status === 401) { location.href = '/login'; throw new Error('signed out'); }
if (!r.ok) throw new Error(body.error || body.reason || r.status);
return body;
}
let me = null;
let catalog = { roles: {}, permissions: [] };
function roleChecks(selected) {
return Object.entries(catalog.roles).map(([id, def]) =>
`<label><input type="checkbox" name="role" value="${id}" ${selected.includes(id) ? 'checked' : ''}/> ${def.title}</label>`
).join('');
}
async function drawUsers() {
const { users } = await j('/users');
$('users').innerHTML = `
<div class="card">
<h3 style="margin:.2rem 0 .8rem">People</h3>
<table><thead><tr><th>User</th><th>Roles</th><th>Permissions</th><th>Status</th><th></th></tr></thead>
<tbody>${users.map((u) => `<tr>
<td><b>${u.name}</b><div class="muted">${u.username}</div></td>
<td>${u.roles.map((r) => `<span class="pill">${r}</span>`).join(' ')}</td>
<td class="muted">${u.permissions.includes('*') ? 'all' : u.permissions.join(', ')}</td>
<td><span class="pill ${u.active ? '' : 'off'}">${u.active ? 'active' : 'inactive'}</span></td>
<td><button class="btn ghost" data-edit="${u.username}">Edit</button></td>
</tr>`).join('')}</tbody></table>
</div>
<div class="card" id="edit-card">
<h3 style="margin:.2rem 0 .8rem">Add staff user</h3>
<div class="row">
<div><label class="muted">Username</label><br><input id="new-user" autocomplete="off"/></div>
<div><label class="muted">Display name</label><br><input id="new-name"/></div>
<div><label class="muted">Password</label><br><input id="new-pass" type="password" autocomplete="new-password"/></div>
</div>
<p class="muted">Roles</p>
<div class="roles" id="new-roles">${roleChecks(['viewer'])}</div>
<p><button class="btn" id="add">Create</button></p>
</div>`;
$('add').onclick = async () => {
const roles = [...document.querySelectorAll('#new-roles input:checked')].map((i) => i.value);
await j('/users', { method: 'POST', body: JSON.stringify({
username: $('new-user').value.trim(),
name: $('new-name').value.trim(),
password: $('new-pass').value,
roles,
}) });
await drawUsers();
};
document.querySelectorAll('[data-edit]').forEach((b) => b.onclick = () => openEdit(users.find((u) => u.username === b.dataset.edit)));
}
function openEdit(u) {
$('edit-card').innerHTML = `
<h3 style="margin:.2rem 0 .8rem">Edit ${u.name}</h3>
<div class="row">
<div><label class="muted">Display name</label><br><input id="ed-name" value="${u.name}"/></div>
<div><label class="muted">New password (optional)</label><br><input id="ed-pass" type="password" autocomplete="new-password"/></div>
</div>
<p class="muted">Roles</p>
<div class="roles" id="ed-roles">${roleChecks(u.roles)}</div>
<p>
<button class="btn" id="save">Save</button>
<button class="btn ghost" id="tog">${u.active ? 'Deactivate' : 'Activate'}</button>
</p>`;
$('save').onclick = async () => {
await j('/users/' + encodeURIComponent(u.username), { method: 'PUT', body: JSON.stringify({
name: $('ed-name').value.trim(),
password: $('ed-pass').value || undefined,
roles: [...document.querySelectorAll('#ed-roles input:checked')].map((i) => i.value),
}) });
await drawUsers();
};
$('tog').onclick = async () => {
await j('/users/' + encodeURIComponent(u.username), { method: 'PUT', body: JSON.stringify({ active: !u.active }) });
await drawUsers();
};
}
async function drawAudit() {
const { audit } = await j('/audit');
$('audit').innerHTML = `<div class="card"><h3 style="margin:.2rem 0 .8rem">Audit</h3>
<table><thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead>
<tbody>${audit.map((a) => `<tr><td class="muted">${a.t}</td><td>${a.actor}</td><td>${a.action}</td><td>${a.target || ''}</td><td class="muted">${a.detail || ''}</td></tr>`).join('') || '<tr><td colspan="5" class="muted">None</td></tr>'}</tbody></table></div>`;
}
function drawRoles() {
$('roles').innerHTML = `<div class="card"><h3 style="margin:.2rem 0 .8rem">Role catalog</h3>
<table><thead><tr><th>Role</th><th>Title</th><th>Permissions</th></tr></thead>
<tbody>${Object.entries(catalog.roles).map(([id, def]) =>
`<tr><td class="pill">${id}</td><td>${def.title}</td><td class="muted">${def.permissions.join(', ')}</td></tr>`
).join('')}</tbody></table>
<p class="muted" style="margin-top:.8rem">Permissions: ${catalog.permissions.join(', ')}</p></div>`;
}
document.querySelectorAll('nav button').forEach((b) => b.onclick = () => {
document.querySelectorAll('nav button').forEach((x) => x.classList.toggle('on', x === b));
document.querySelectorAll('.panel').forEach((p) => { p.hidden = p.id !== b.dataset.tab; });
if (b.dataset.tab === 'audit') drawAudit().catch(console.error);
});
(async () => {
catalog = await j('/roles');
const { user } = await j('/me');
me = user;
$('who').textContent = `${user.name} · ${user.username} · ${user.roles.join(', ')}`;
if (!user.permissions.includes('*') && !user.permissions.includes('iam.users.read')) {
$('users').innerHTML = `<div class="card">Signed in as ${user.username}. You do not have iam.users.read — ask an owner to grant IAM admin.</div>`;
return;
}
await drawUsers();
drawRoles();
})().catch((err) => { $('users').innerHTML = `<div class="card">${err.message}</div>`; });
</script>
</body>
</html>

View file

@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Staff sign-in</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%234f46e5'/%3E%3C/svg%3E"/>
<style>
:root { --accent:#4f46e5; --bg:#f4f5fb; --ink:#171a26; --muted:#6b7186; --line:#e5e7f0; }
* { box-sizing:border-box; }
body { margin:0; font:14px/1.45 -apple-system,"SF Pro Text","Segoe UI",sans-serif; }
#auth { min-height:100vh; display:grid; place-items:center; background:linear-gradient(160deg,#312e81 0%,#4f46e5 55%,#7c74f0 100%); }
.card { width:380px; background:#fff; border-radius:16px; padding:2rem; box-shadow:0 24px 64px rgba(17,12,60,.35); }
h1 { margin:0 0 .25rem; font-size:1.3rem; }
p { color:var(--muted); margin:0 0 1.1rem; }
label { display:block; font-size:.8rem; font-weight:700; margin:.8rem 0 .3rem; }
input { width:100%; padding:.6rem .75rem; border:1px solid var(--line); border-radius:8px; }
button { width:100%; margin-top:1.2rem; padding:.65rem; border:0; border-radius:8px; background:var(--accent); color:#fff; font-weight:700; cursor:pointer; }
.err { color:#dc2626; min-height:1.2em; }
</style>
</head>
<body>
<section id="auth">
<form class="card" method="post" action="/login">
<h1>Staff sign-in</h1>
<p>Named account. Roles decide CS, sales, accounting, fleet, and billing admin.</p>
<input type="hidden" name="next" id="next"/>
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" required/>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required/>
<p class="err" id="err"></p>
<button type="submit">Sign in</button>
</form>
</section>
<script>
const q = new URLSearchParams(location.search);
document.getElementById('next').value = q.get('next') || '/';
if (q.get('error')) document.getElementById('err').textContent = 'Invalid username or password.';
</script>
</body>
</html>

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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,
};
}

View file

@ -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`);
});

View file

@ -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] : '';
}

View file

@ -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);
}

View file

@ -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');
}
});

View file

@ -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']);
});

View file

@ -50,8 +50,35 @@ const server = http.createServer(async (req, res) => {
res.writeHead(code, { 'content-type': 'application/json' }); res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(obj)); 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') { 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')) { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });

View file

@ -32,6 +32,13 @@
- Disable lan-134 unless `FLEET_ENABLE_LAN134=1`. - Disable lan-134 unless `FLEET_ENABLE_LAN134=1`.
- SSH spawn timeout 8s; failed hosts skipped. - SSH spawn timeout 8s; failed hosts skipped.
## 2026-09-11 — 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 ## 2026-09-11 — last three UI leftovers
- Swagger `/docs` stays stock; banner names it OpenAPI explorer. - Swagger `/docs` stays stock; banner names it OpenAPI explorer.

View file

@ -4,5 +4,6 @@
- [ ] NATS nkeys/mTLS on a real three-node cluster (accounts file is the lab stand-in). - [ ] 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] 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] 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. - [ ] Zapier Platform `push` of a private app.
- [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge). - [ ] Move portal static files fully into `verae-access-web` (today it proxies `/portal` to loopback edge).

View file

@ -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;
}

View file

@ -6,6 +6,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js'; import { SUBJECTS, billingRequest } from './nats-billing.js';
import { listCustomers, withCustomerName } from './names.js'; import { listCustomers, withCustomerName } from './names.js';
import { denyOrRedirect } from './iam-gate.js';
const PORT = Number(process.env.PORT || 3013); const PORT = Number(process.env.PORT || 3013);
const EDGE = (process.env.ZAPPIER_ADMIN_URL || 'http://127.0.0.1:3000').replace(/\/$/, ''); 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)); const json = (code, obj) => send(code, 'application/json', JSON.stringify(obj));
try { try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
if (process.env.STAFF_AUTH === '1') { if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review', html: true }))) return;
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;
}
}
return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html'))); return send(200, 'text/html; charset=utf-8', fs.readFileSync(path.join(PUBLIC, 'index.html')));
} }
if (req.method === 'GET' && url.pathname === '/health') { if (req.method === 'GET' && url.pathname === '/health') {
@ -46,6 +39,7 @@ const server = http.createServer(async (req, res) => {
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) { if (req.method === 'GET' && review) {
if (!(await denyOrRedirect(req, res, json, { permission: 'accounting.review' }))) return;
const id = decodeURIComponent(review[1]); const id = decodeURIComponent(review[1]);
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id }); const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id });
if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY)); 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 period = url.searchParams.get('period');
const q = period ? `?period=${encodeURIComponent(period)}` : ''; const q = period ? `?period=${encodeURIComponent(period)}` : '';
if (req.method === 'GET' && url.pathname === '/export/quickbooks.iif') { 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}`); const r = await edge(`/admin/api/exports/quickbooks.iif${q}`);
return send(r.status, 'text/plain', await r.text()); return send(r.status, 'text/plain', await r.text());
} }
if (req.method === 'GET' && url.pathname === '/export/accounting.csv') { 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}`); const r = await edge(`/admin/api/exports/accounting.csv${q}`);
return send(r.status, 'text/csv', await r.text()); return send(r.status, 'text/csv', await r.text());
} }

View file

@ -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;
}

View file

@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js'; import { SUBJECTS, billingRequest } from './nats-billing.js';
import { listCustomers, withCustomerName } from './names.js'; import { listCustomers, withCustomerName } from './names.js';
import { staffPageHtml } from './staff-page.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 PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -34,16 +35,7 @@ const server = http.createServer(async (req, res) => {
}; };
try { try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
if (process.env.STAFF_AUTH === '1') { if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review', html: true }))) return;
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;
}
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end( res.end(
await staffPageHtml( await staffPageHtml(
@ -65,12 +57,14 @@ const server = http.createServer(async (req, res) => {
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) { if (req.method === 'GET' && review) {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.review' }))) return;
const id = decodeURIComponent(review[1]); const id = decodeURIComponent(review[1]);
const out = await statement(id); const out = await statement(id);
out.body = await withCustomerName(out.body, id, EDGE, KEY); out.body = await withCustomerName(out.body, id, EDGE, KEY);
return json(out.status, out.body); return json(out.status, out.body);
} }
if (req.method === 'POST' && url.pathname === '/credits') { if (req.method === 'POST' && url.pathname === '/credits') {
if (!(await denyOrRedirect(req, res, json, { permission: 'cs.credit' }))) return;
const chunks = []; const chunks = [];
for await (const c of req) chunks.push(c); for await (const c of req) chunks.push(c);
const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); const payload = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');

View file

@ -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;
}

View file

@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import { SUBJECTS, billingRequest } from './nats-billing.js'; import { SUBJECTS, billingRequest } from './nats-billing.js';
import { listCustomers, withCustomerName } from './names.js'; import { listCustomers, withCustomerName } from './names.js';
import { denyOrRedirect } from './iam-gate.js';
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
@ -37,15 +38,7 @@ const server = http.createServer(async (req, res) => {
}; };
try { try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) { if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
if (process.env.STAFF_AUTH === '1') { if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review', html: true }))) return;
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;
}
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(fs.readFileSync(path.join(PUBLIC, 'index.html'))); res.end(fs.readFileSync(path.join(PUBLIC, 'index.html')));
return; return;
@ -58,6 +51,7 @@ const server = http.createServer(async (req, res) => {
} }
const review = url.pathname.match(/^\/review\/([^/]+)$/); const review = url.pathname.match(/^\/review\/([^/]+)$/);
if (req.method === 'GET' && review) { if (req.method === 'GET' && review) {
if (!(await denyOrRedirect(req, res, json, { permission: 'sales.review' }))) return;
const id = decodeURIComponent(review[1]); const id = decodeURIComponent(review[1]);
const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id }); const nats = await billingRequest(SUBJECTS.STATEMENT_GET, { customerId: id });
if (nats) return json(200, await withCustomerName({ ...nats, source: 'nats' }, id, EDGE, KEY)); 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\/([^/]+)$/); const quote = url.pathname.match(/^\/quotes\/([^/]+)$/);
if (req.method === 'GET' && quote) { 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]}`); const forwarded = await edge(`/admin/api/sales/quote/${quote[1]}`);
return json(forwarded.status, forwarded.body); return json(forwarded.status, forwarded.body);
} }

View file

@ -92,11 +92,41 @@ function currentPeriod() {
return new Date().toISOString().slice(0, 7); 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() { 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 =
`<h2>Staff IAM</h2><p class="lede">This console is for billing admins. Manage people at <a href="${state.me.iamUrl}" style="color:var(--accent)">${state.me.iamUrl}</a>.</p>`;
}
}
if (!can('admin.pricing') && !can('admin.tiers')) return;
state.pricing = await api('/pricing'); state.pricing = await api('/pricing');
state.customers = (await api('/customers')).customers; state.customers = can('admin.customers') ? (await api('/customers')).customers : [];
state.invoices = (await api('/invoices')).invoices; state.invoices = can('admin.invoices') ? (await api('/invoices')).invoices : [];
state.users = (await api('/users')).users; state.users = can('iam.users.read') || !state.me?.iam ? (await api('/users')).users : [];
renderEndpoints(); renderEndpoints();
renderTiers(); renderTiers();
renderCustomers(); renderCustomers();
@ -703,6 +733,16 @@ async function loadSystem() {
/* ---------------- admin users ---------------- */ /* ---------------- admin users ---------------- */
function renderUsers() { function renderUsers() {
if (state.me?.iam) {
document.getElementById('users').innerHTML = `
<h2>Staff users</h2>
<p class="lede">Named internal accounts and roles live in Staff IAM not the billing-console seed table.</p>
<div class="card">
<p>Open <a class="btn" style="display:inline-block;text-decoration:none" href="${state.me.iamUrl || 'http://127.0.0.1:3028/'}">${state.me.iamUrl || 'http://127.0.0.1:3028/'}</a></p>
<p class="hint">Roles: owner, iam-admin, billing-admin, cs, sales, accounting, operator, viewer.</p>
</div>`;
return;
}
const rows = state.users const rows = state.users
.slice() .slice()
.sort((a, b) => a.username.localeCompare(b.username)) .sort((a, b) => a.username.localeCompare(b.username))

View file

@ -15,26 +15,43 @@ import { invoicesToAccountingCsv, invoicesToQuickBooksIif } from './accounting-e
import { composeStatement } from './statement'; import { composeStatement } from './statement';
import { BILLING_SUBJECTS, natsPublish } from './billing-nats'; import { BILLING_SUBJECTS, natsPublish } from './billing-nats';
import { booksConfigured, ledgerAdjust, ledgerPutCustomer, ledgerStatement } from './ledger'; 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). // Issued login tokens (in-memory; a restart simply requires logging in again).
const sessions = new Map<string, number>(); const sessions = new Map<string, StaffSession>();
declare module 'express-serve-static-core' {
interface Request {
staff?: StaffSession;
}
}
export function adminLoginRouter(users: AdminUserRepo): Router { export function adminLoginRouter(users: AdminUserRepo): Router {
const router = Router(); const router = Router();
router.post('/login', (req, res) => { router.post('/login', async (req, res) => {
const { username, password } = req.body ?? {}; const { username, password } = req.body ?? {};
if (typeof username !== 'string' || typeof password !== 'string') { if (typeof username !== 'string' || typeof password !== 'string') {
res.status(401).json({ error: 'invalid username or password' }); res.status(401).json({ error: 'invalid username or password' });
return; 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); const user = users.findByUsername(username);
if (!user || !user.active || !verifyPassword(password, user.passwordHash)) { if (!user || !user.active || !verifyPassword(password, user.passwordHash)) {
res.status(401).json({ error: 'invalid username or password' }); res.status(401).json({ error: 'invalid username or password' });
return; return;
} }
const token = randomBytes(24).toString('hex'); const token = randomBytes(24).toString('hex');
sessions.set(token, Date.now()); sessions.set(token, { username, permissions: ['*'], t: Date.now() });
res.json({ token }); res.json({ token, user: { username, permissions: ['*'] } });
}); });
return router; return router;
} }
@ -42,14 +59,33 @@ export function adminLoginRouter(users: AdminUserRepo): Router {
export function adminAuth(): RequestHandler { export function adminAuth(): RequestHandler {
return (req, res, next) => { return (req, res, next) => {
const expected = process.env.ADMIN_KEY ?? 'admin-dev-key'; 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 bearer = req.header('authorization');
const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : undefined; 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' }); 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 { function isValidRule(rule: unknown): rule is PriceRule {
if (!rule || typeof rule !== 'object') return false; if (!rule || typeof rule !== 'object') return false;
const r = rule as Record<string, unknown>; const r = rule as Record<string, unknown>;
@ -148,6 +184,17 @@ export function adminRouter(
): Router { ): Router {
const router = 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) => { router.get('/pricing', (req, res) => {
res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() }); res.json({ rateCard: store.getRateCard(), tiers: store.getTiers() });
}); });

View file

@ -19,7 +19,7 @@ import {
PricingStore, PricingStore,
} from './pricing'; } from './pricing';
import { InMemoryUsageRepo, UsageRepo } from './usage'; 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 { AdminUserRepo, InMemoryAdminUserRepo, seedAdminUsersFromEnv } from './admin-users';
import { InMemorySessionRepo, SessionRepo } from './accounts'; import { InMemorySessionRepo, SessionRepo } from './accounts';
import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing'; import { InMemoryInvoiceRepo, InvoiceRepo } from './invoicing';
@ -135,6 +135,7 @@ export function buildApp(deps: AppDeps = {}): {
'/admin/api', '/admin/api',
adminLoginRouter(adminUsers), adminLoginRouter(adminUsers),
adminAuth(), adminAuth(),
adminPerms(),
adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits), adminRouter(pricingStore, customers, { usage, invoices, users: adminUsers }, credits),
); );
app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin'))); app.use('/admin', express.static(path.join(PROJECT_ROOT, 'admin')));

View file

@ -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(),
},
};
}

View file

@ -367,6 +367,7 @@ def main() -> None:
"ui-docs", "ui-docs",
"verae-staff-session", "verae-staff-session",
"verae-staff-ui", "verae-staff-ui",
"verae-staff-iam",
): ):
pkg_root = ROOT / "packages" / pkg pkg_root = ROOT / "packages" / pkg
if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}: if pkg in {"zapier-user-docs", "overview", "docs-master", "verae-ops"}:

View file

@ -70,6 +70,7 @@ REPO_READMES = [
"ui-docs", "ui-docs",
"verae-staff-session", "verae-staff-session",
"verae-staff-ui", "verae-staff-ui",
"verae-staff-iam",
] ]
SKIP_PARTS = {"test", "tests", "node_modules", "dist"} SKIP_PARTS = {"test", "tests", "node_modules", "dist"}

View file

@ -67,6 +67,7 @@ create zapier-decisions "Architecture decisions and action log"
create UI-Docs "UI walkthrough, screenshots, and review PDF" create UI-Docs "UI walkthrough, screenshots, and review PDF"
create verae-staff-session "Shared staff cookie login for department HTML" create verae-staff-session "Shared staff cookie login for department HTML"
create verae-staff-ui "Shared staff review HTML template" 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/zappier" zappier-edge
push_dir "$ROOT/packages/verae-zapier-middleware" verae-middleware 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/ui-docs" UI-Docs
push_dir "$ROOT/packages/verae-staff-session" verae-staff-session 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-ui" verae-staff-ui
push_dir "$ROOT/packages/verae-staff-iam" verae-staff-iam
echo ALL_MODULE_REPOS_PUSHED echo ALL_MODULE_REPOS_PUSHED