feat: GET /roles/catalog and /users/:id/roles for starter/ender/admin

Public catalog for dual-staff SoT when edge STAFF_IAM_URL is set.
This commit is contained in:
George Lambert 2026-10-14 00:55:27 -04:00
commit 566b29f4ac
6 changed files with 105 additions and 5 deletions

View file

@ -84,7 +84,23 @@ STAFF_AUTH=1 # department HTML still redirects if check fails
## Roles
`owner`, `iam-admin`, `billing-admin`, `cs`, `sales`, `accounting`, `operator`, `viewer`. Permissions are listed on `/roles`.
`owner`, `admin`, `iam-admin`, `billing-admin`, `cs`, `starter`, `ender`, `sales`, `accounting`, `operator`, `viewer`. Full permission map: `GET /roles`.
### Product catalog (edge dual-staff)
```http
GET /roles/catalog
→ { "roles": ["starter", "ender", "admin"] }
```
Public, no auth. Edge (`STAFF_IAM_URL`) uses this catalog when dual-staff impersonate is on. Per-user roles:
```http
GET /users/:usernameOrId/roles
→ { "id", "username", "active", "roles": ["ender", …] }
```
Roles-only (no secrets). Unknown/inactive → **404** `{ error, roles: [] }`. Assign `starter` / `ender` / `admin` on people like any other role id.
## Active role interface

10
TOTP.md
View file

@ -32,7 +32,7 @@ POST /login ──► session issued (staff_session cookie / token)
full APIs / doors ──► 403 { error: "totp_enrollment_required", … }
│
│ still allowed while gated:
│ GET /me, /enroll/status, /health, /roles
│ GET /me, /enroll/status, /health, /roles, /roles/catalog
│ POST /2fa/setup, /2fa/enable, /logout
▼
POST /2fa/setup ──► secret + otpauth URI (+ QR if qrcode dep present)
@ -60,11 +60,19 @@ GET /me
GET /enroll/status
GET /health
GET /roles
GET /roles/catalog
POST /2fa/setup
POST /2fa/enable
POST /logout
```
Public (no session) role surfaces used by edge dual-staff:
```
GET /roles/catalog → { roles: ["starter","ender","admin"] }
GET /users/:idOrUsername/roles → { id, username, active, roles[] } (no secrets)
```
HTML static doors and the in-app **totp-gate** modal use setup/enable only until unlock.
## `/enroll/status` TOTP fields

View file

@ -25,6 +25,11 @@ export const ROLES = Object.freeze({
title: 'Admin',
permissions: ['*'],
},
/** Product catalog alias of owner (edge dual-staff / IMPERSONATE_*_ROLE). */
admin: {
title: 'Admin',
permissions: ['*'],
},
'iam-admin': {
title: 'IAM admin',
permissions: ['iam.users.read', 'iam.users.write'],
@ -45,6 +50,16 @@ export const ROLES = Object.freeze({
title: 'Customer service',
permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'],
},
/** Impersonate ticket issuer (edge IMPERSONATE_STARTER_ROLE). */
starter: {
title: 'Support starter',
permissions: ['cs.review', 'admin.statement', 'staff.plane'],
},
/** Impersonate exchange approver (edge IMPERSONATE_APPROVER_ROLE / dual-staff ender). */
ender: {
title: 'Support ender',
permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'],
},
sales: {
title: 'Sales',
permissions: ['sales.review', 'sales.quote', 'admin.statement', 'staff.plane'],
@ -63,12 +78,21 @@ export const ROLES = Object.freeze({
},
});
/**
* Product role catalog for edge dual-staff / impersonate (subset of ROLES).
* GET /roles/catalog → { roles: CATALOG_ROLES }.
*/
export const CATALOG_ROLES = Object.freeze(['starter', 'ender', 'admin']);
/** Role → staff UI door. Overridable via STAFF_*_URL env. */
export const ROLE_INTERFACES = Object.freeze({
cs: { title: 'Customer service', env: 'STAFF_CS_URL', pathHint: 'cs' },
starter: { title: 'Customer service', env: 'STAFF_CS_URL', pathHint: 'cs' },
ender: { title: 'Customer service', env: 'STAFF_CS_URL', pathHint: 'cs' },
sales: { title: 'Sales', env: 'STAFF_SALES_URL', pathHint: 'sales' },
accounting: { title: 'Accounting', env: 'STAFF_ACCT_URL', pathHint: 'acct' },
owner: { title: 'Admin', env: 'STAFF_ADMIN_URL', pathHint: 'iam' },
admin: { title: 'Admin', env: 'STAFF_ADMIN_URL', pathHint: 'iam' },
'iam-admin': { title: 'IAM admin', env: 'STAFF_IAM_UI_URL', pathHint: 'iam' },
'billing-admin': { title: 'Billing admin', env: 'STAFF_PLANE_URL', pathHint: 'staff' },
viewer: { title: 'Staff plane', env: 'STAFF_PLANE_URL', pathHint: 'staff' },

View file

@ -3,7 +3,7 @@ import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
import { allows, publicUser, ROLES, PERMISSIONS, ROLE_INTERFACES, CATALOG_ROLES, roleInterfaceUrl, inferRoleFromNext, interfacesForRoles } from './roles.js';
import { verifyPassword } from './passwords.js';
import { loadIam, saveIam } from './store.js';
import {
@ -142,7 +142,7 @@ function totpEnrollAllowed(method, pathname) {
if (pathname === '/enroll/csr' && method === 'POST') return true;
if (pathname === '/logout') return true;
if (pathname === '/2fa/setup' || pathname === '/2fa/enable') return method === 'POST';
if (pathname === '/roles') return method === 'GET';
if (pathname === '/roles' || pathname === '/roles/catalog') return method === 'GET';
return false;
}
@ -370,6 +370,10 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/roles') {
return json(200, { roles: ROLES, permissions: PERMISSIONS, interfaces: ROLE_INTERFACES });
}
// Product catalog for edge dual-staff / impersonate (public, no secrets).
if (req.method === 'GET' && url.pathname === '/roles/catalog') {
return json(200, { roles: [...CATALOG_ROLES] });
}
if (req.method === 'GET' && url.pathname === '/interfaces') {
const me = actorOf(req);
if (!me) return json(401, { ok: false, reason: 'not signed in' });
@ -714,6 +718,25 @@ const server = http.createServer(async (req, res) => {
if (!me) return;
return json(200, { users: iam.users.map((u) => publicUser(u)) });
}
// Roles-only lookup by username or id (no secrets). Public for edge STAFF_IAM_URL dual-staff.
const userRoles = url.pathname.match(/^\/users\/([^/]+)\/roles$/);
if (req.method === 'GET' && userRoles) {
const key = decodeURIComponent(userRoles[1] || '').trim();
if (!key) return json(400, { error: 'missing user id' });
const user =
iam.findByUsername(key) ||
iam.findById(key) ||
iam.users.find((u) => (u.username || '').toLowerCase() === key.toLowerCase());
if (!user || user.active === false) {
return json(404, { error: 'not found', roles: [] });
}
return json(200, {
id: user.id,
username: user.username,
active: user.active !== false,
roles: [...(user.roles || [])],
});
}
if (req.method === 'GET' && url.pathname === '/audit') {
const me = requirePerm(req, res, json, 'iam.users.read');
if (!me) return;

View file

@ -20,6 +20,8 @@ test('iam login, permission check, user CRUD', async () => {
try {
const h = await (await fetch(`http://127.0.0.1:${port}/health`)).json();
assert.equal(h.role, 'verae-staff-iam');
const catalog = await (await fetch(`http://127.0.0.1:${port}/roles/catalog`)).json();
assert.deepEqual(catalog.roles, ['starter', 'ender', 'admin']);
const login = await fetch(`http://127.0.0.1:${port}/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
@ -51,6 +53,22 @@ test('iam login, permission check, user CRUD', async () => {
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'));
const patRoles = await (await fetch(`http://127.0.0.1:${port}/users/pat/roles`)).json();
assert.equal(patRoles.username, 'pat');
assert.deepEqual(patRoles.roles, ['sales']);
assert.equal(patRoles.active, true);
const byId = await (await fetch(`http://127.0.0.1:${port}/users/${encodeURIComponent(body.id)}/roles`)).json();
assert.deepEqual(byId.roles, ['sales']);
const missing = await fetch(`http://127.0.0.1:${port}/users/no-such-user/roles`);
assert.equal(missing.status, 404);
assert.deepEqual((await missing.json()).roles, []);
const enderUser = await fetch(`http://127.0.0.1:${port}/users`, {
method: 'POST',
headers: hdr,
body: JSON.stringify({ username: 'ender1', password: 'ender-pass-1', name: 'Ender', roles: ['ender', 'starter'] }),
});
assert.equal(enderUser.status, 201);
assert.deepEqual((await enderUser.json()).roles.sort(), ['ender', 'starter']);
/* TOTP: setup → enable → login requires code */
const { totp, verifyTotp } = await import('../src/totp.js');

View file

@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { expandRoles, allows, ROLES, publicUser, inferRoleFromNext, interfacesForRoles } from '../src/roles.js';
import { expandRoles, allows, ROLES, CATALOG_ROLES, publicUser, inferRoleFromNext, interfacesForRoles } from '../src/roles.js';
import { StaffIam } from '../src/store.js';
import { verifyPassword } from '../src/passwords.js';
@ -12,6 +12,17 @@ test('owner expands to all permissions', () => {
assert.ok(ROLES.sales);
});
test('product catalog is starter/ender/admin and roles exist', () => {
assert.deepEqual([...CATALOG_ROLES], ['starter', 'ender', 'admin']);
assert.ok(ROLES.starter);
assert.ok(ROLES.ender);
assert.ok(ROLES.admin);
assert.deepEqual(expandRoles(['admin']), ['*']);
assert.equal(allows(expandRoles(['starter']), 'cs.review'), true);
assert.equal(allows(expandRoles(['starter']), 'cs.credit'), false);
assert.equal(allows(expandRoles(['ender']), 'cs.credit'), true);
});
test('sessions persist in dump/load including currentRole', () => {
const iam = new StaffIam().seed();
const u = iam.findByUsername('cs');