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:
parent
5e2d0b979a
commit
566b29f4ac
6 changed files with 105 additions and 5 deletions
18
README.md
18
README.md
|
|
@ -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
10
TOTP.md
|
|
@ -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
|
||||
|
|
|
|||
24
src/roles.js
24
src/roles.js
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue