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

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