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
|
|
@ -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() });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue