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

@ -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<string, number>();
const sessions = new Map<string, StaffSession>();
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<string, unknown>;
@ -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() });
});