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

@ -0,0 +1,57 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
test('iam login, permission check, user CRUD', async () => {
const port = 18028;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iam-'));
const child = spawn(process.execPath, ['src/server.js'], {
cwd: root,
env: { ...process.env, PORT: String(port), STAFF_IAM_PATH: path.join(dir, 'iam.json') },
stdio: ['ignore', 'pipe', 'pipe'],
});
await new Promise((r) => setTimeout(r, 500));
try {
const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json();
assert.equal(h.role, 'verae-staff-iam');
const login = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'admin-dev-key' }),
});
assert.equal(login.status, 200);
const { token, user } = await login.json();
assert.ok(user.permissions.includes('*'));
const hdr = { authorization: `Bearer ${token}`, 'content-type': 'application/json' };
const ok = await fetch(`http://127.0.0.1:${port}/check?permission=cs.credit`, { headers: hdr });
assert.equal(ok.status, 200);
const csLogin = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'cs', password: 'cs-dev-key' }),
});
const cs = await csLogin.json();
const deny = await fetch(`http://127.0.0.1:${port}/check?permission=accounting.export`, {
headers: { authorization: `Bearer ${cs.token}` },
});
assert.equal(deny.status, 403);
const created = await fetch(`http://127.0.0.1:${port}/users`, {
method: 'POST',
headers: hdr,
body: JSON.stringify({ username: 'pat', password: 'pat-pass-99', name: 'Pat', roles: ['sales'] }),
});
assert.equal(created.status, 201);
const body = await created.json();
assert.deepEqual(body.roles, ['sales']);
const list = await (await fetch(`http://127.0.0.1:${port}/users`, { headers: hdr })).json();
assert.ok(list.users.some((u) => u.username === 'pat'));
} finally {
child.kill('SIGTERM');
}
});

View file

@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { expandRoles, allows, ROLES } from '../src/roles.js';
import { StaffIam } from '../src/store.js';
import { verifyPassword } from '../src/passwords.js';
test('owner expands to all permissions', () => {
assert.deepEqual(expandRoles(['owner']), ['*']);
assert.equal(allows(['*'], 'cs.credit'), true);
assert.equal(allows(expandRoles(['cs']), 'cs.credit'), true);
assert.equal(allows(expandRoles(['cs']), 'accounting.export'), false);
assert.ok(ROLES.sales);
});
test('cannot deactivate last owner; passwords hash', () => {
const iam = new StaffIam().seed();
const owner = iam.findByUsername('admin');
assert.ok(verifyPassword(process.env.ADMIN_KEY || 'admin-dev-key', owner.passwordHash));
assert.throws(() => iam.update('admin', { active: false }, 'admin'), /last owner/);
const cs = iam.create({ username: 'anna', password: 'anna-pass-1', name: 'Anna', roles: ['cs'], actor: 'admin' });
assert.deepEqual(cs.roles, ['cs']);
});