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
|
|
@ -92,11 +92,41 @@ function currentPeriod() {
|
|||
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() {
|
||||
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.customers = (await api('/customers')).customers;
|
||||
state.invoices = (await api('/invoices')).invoices;
|
||||
state.users = (await api('/users')).users;
|
||||
state.customers = can('admin.customers') ? (await api('/customers')).customers : [];
|
||||
state.invoices = can('admin.invoices') ? (await api('/invoices')).invoices : [];
|
||||
state.users = can('iam.users.read') || !state.me?.iam ? (await api('/users')).users : [];
|
||||
renderEndpoints();
|
||||
renderTiers();
|
||||
renderCustomers();
|
||||
|
|
@ -703,6 +733,16 @@ async function loadSystem() {
|
|||
/* ---------------- admin users ---------------- */
|
||||
|
||||
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
|
||||
.slice()
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
|
|
|
|||
|
|
@ -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() });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
|
|
|
|||
61
packages/zappier/src/staff-iam.ts
Normal file
61
packages/zappier/src/staff-iam.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue