Add internal staff IAM: named users, roles, and management permissions.
Some checks are pending
offline / test (push) Waiting to run
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:
parent
2740d51446
commit
d299d245e8
41 changed files with 1337 additions and 52 deletions
180
packages/verae-staff-iam/src/server.js
Normal file
180
packages/verae-staff-iam/src/server.js
Normal 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`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue