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

View file

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

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