From 84fd73fe95cbbb1358e5daa995fe8b38ba917b4f Mon Sep 17 00:00:00 2001 From: George Lambert Date: Fri, 11 Sep 2026 19:55:17 -0400 Subject: [PATCH] Initial import of verae-staff-iam from zapier monorepo --- NATS.md | 3 + README.md | 32 +++++++ SUMMARY.md | 3 + package.json | 11 +++ public/app.html | 170 +++++++++++++++++++++++++++++++++++++ public/login.html | 42 +++++++++ src/client.js | 47 +++++++++++ src/passwords.js | 26 ++++++ src/roles.js | 98 +++++++++++++++++++++ src/server.js | 189 +++++++++++++++++++++++++++++++++++++++++ src/sessions.js | 59 +++++++++++++ src/store.js | 201 ++++++++++++++++++++++++++++++++++++++++++++ test/health.test.js | 57 +++++++++++++ test/roles.test.js | 31 +++++++ 14 files changed, 969 insertions(+) create mode 100644 NATS.md create mode 100644 README.md create mode 100644 SUMMARY.md create mode 100644 package.json create mode 100644 public/app.html create mode 100644 public/login.html create mode 100644 src/client.js create mode 100644 src/passwords.js create mode 100644 src/roles.js create mode 100644 src/server.js create mode 100644 src/sessions.js create mode 100644 src/store.js create mode 100644 test/health.test.js create mode 100644 test/roles.test.js diff --git a/NATS.md b/NATS.md new file mode 100644 index 0000000..199aa61 --- /dev/null +++ b/NATS.md @@ -0,0 +1,3 @@ +# NATS + +HTTP only. Department doors call `GET /check?permission=`. Authz stays plane-based; IAM is the people hop in front of the staff plane. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b56782f --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# verae-staff-iam + +Named **internal staff** accounts with **roles and permissions**. Source of truth for who may use CS, sales, accounting, the staff plane, the operator console, and the billing admin console. + +**Forgejo:** https://git.georgelambert.org/marchon/verae-staff-iam + +Port **`:3028`**. UI: sign-in, people, roles, audit. + +## Seed lab users + +| Username | Password | Roles | +|----------|----------|--------| +| `admin` | `admin-dev-key` | owner (all) | +| `cs` | `cs-dev-key` | cs | +| `sales` | `sales-dev-key` | sales | +| `accounting` | `acct-dev-key` | accounting | +| `operator` | `fleet-dev-key` | operator | + +Override with `IAM_OWNER_PASSWORD`, `IAM_CS_PASSWORD`, etc. Persist: `STAFF_IAM_PATH` (users, audit, **sessions**). Login is rate-limited (8 failures / 10 minutes / IP+username). JSON APIs return 401/403; HTML doors 302 to `/login`. + +## Wire other doors + +```bash +STAFF_IAM_URL=http://127.0.0.1:3028 +STAFF_AUTH=1 # department HTML still redirects if check fails +``` + +`GET /check?permission=cs.credit` — cookie or `Authorization: Bearer`. Cookie name remains `staff_session`. Multi-host: `STAFF_COOKIE_DOMAIN`. + +## Roles + +`owner`, `iam-admin`, `billing-admin`, `cs`, `sales`, `accounting`, `operator`, `viewer`. Permissions are listed on `/roles`. diff --git a/SUMMARY.md b/SUMMARY.md new file mode 100644 index 0000000..b07e966 --- /dev/null +++ b/SUMMARY.md @@ -0,0 +1,3 @@ +# verae-staff-iam + +Internal staff directory: users, roles, permissions, sessions, audit. Not customer portal accounts. diff --git a/package.json b/package.json new file mode 100644 index 0000000..8283d8b --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "verae-staff-iam", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Internal staff user management: named accounts, roles, permissions, audit", + "scripts": { + "start": "node src/server.js", + "test": "node --test test/*.test.js" + } +} diff --git a/public/app.html b/public/app.html new file mode 100644 index 0000000..46573e6 --- /dev/null +++ b/public/app.html @@ -0,0 +1,170 @@ + + + + + + Staff IAM + + + + +
+
+
internal staff · permissions
+

Staff IAM

+

+
+ +
+
+
+ + +
+ + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..2616bee --- /dev/null +++ b/public/login.html @@ -0,0 +1,42 @@ + + + + + + Staff sign-in + + + + +
+
+

Staff sign-in

+

Named account. Roles decide CS, sales, accounting, fleet, and billing admin.

+ + + + + +

+ +
+
+ + + diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..13d3b6c --- /dev/null +++ b/src/client.js @@ -0,0 +1,47 @@ +/** HTTP client used by department doors and the admin console. */ + +export function iamBase() { + return (process.env.STAFF_IAM_URL || '').replace(/\/$/, ''); +} + +export async function iamCheck(req, permission) { + const base = iamBase(); + if (!base) { + if (process.env.STAFF_AUTH === '1') { + const login = (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3027').replace(/\/$/, ''); + const r = await fetch(`${login}/check`, { headers: { cookie: req.headers.cookie || '' } }).catch(() => null); + return { ok: Boolean(r && r.ok), skipped: false, legacy: true }; + } + return { ok: true, skipped: true }; + } + const q = permission ? `?permission=${encodeURIComponent(permission)}` : ''; + const r = await fetch(`${base}/check${q}`, { + headers: { + cookie: req.headers.cookie || '', + authorization: req.headers.authorization || '', + }, + }).catch(() => null); + if (!r) return { ok: false, status: 502 }; + const body = await r.json().catch(() => ({})); + return { ok: r.ok, status: r.status, ...body }; +} + +export function iamLoginUrl(next) { + const base = iamBase() || (process.env.STAFF_SESSION_URL || 'http://127.0.0.1:3028').replace(/\/$/, ''); + return `${base}/login?next=${encodeURIComponent(next)}`; +} + +export async function denyOrRedirect(req, res, { permission, html, json }) { + const out = await iamCheck(req, permission); + if (out.ok) return out; + if (html) { + res.writeHead(302, { location: iamLoginUrl(`http://${req.headers.host || '127.0.0.1'}/`) }); + res.end(); + return out; + } + json(out.status === 403 ? 403 : 401, { + error: out.reason || 'unauthorized', + permission: permission || undefined, + }); + return out; +} diff --git a/src/passwords.js b/src/passwords.js new file mode 100644 index 0000000..47a3543 --- /dev/null +++ b/src/passwords.js @@ -0,0 +1,26 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +const N = 16384; +const R = 8; +const P = 1; +const KEY_LEN = 32; + +export function hashPassword(password) { + const salt = randomBytes(16); + const hash = scryptSync(String(password), salt, KEY_LEN, { N, r: R, p: P }); + return `scrypt:${N}:${R}:${P}:${salt.toString('base64')}:${hash.toString('base64')}`; +} + +export function verifyPassword(password, stored) { + const parts = String(stored || '').split(':'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + const [, n, r, p, saltB64, hashB64] = parts; + const expected = Buffer.from(hashB64, 'base64'); + if (!expected.length) return false; + const actual = scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, { + N: Number(n), + r: Number(r), + p: Number(p), + }); + return timingSafeEqual(actual, expected); +} diff --git a/src/roles.js b/src/roles.js new file mode 100644 index 0000000..fe821c6 --- /dev/null +++ b/src/roles.js @@ -0,0 +1,98 @@ +/** Internal staff roles → permissions. Default deny on unknown. */ + +export const PERMISSIONS = Object.freeze([ + 'iam.users.read', + 'iam.users.write', + 'admin.pricing', + 'admin.tiers', + 'admin.customers', + 'admin.statement', + 'admin.invoices', + 'admin.reports', + 'admin.system', + 'cs.review', + 'cs.credit', + 'sales.review', + 'sales.quote', + 'accounting.review', + 'accounting.export', + 'staff.plane', + 'fleet.operate', +]); + +export const ROLES = Object.freeze({ + owner: { + title: 'Owner', + permissions: ['*'], + }, + 'iam-admin': { + title: 'IAM admin', + permissions: ['iam.users.read', 'iam.users.write'], + }, + 'billing-admin': { + title: 'Billing admin', + permissions: [ + 'admin.pricing', + 'admin.tiers', + 'admin.customers', + 'admin.statement', + 'admin.invoices', + 'admin.reports', + 'admin.system', + ], + }, + cs: { + title: 'Customer service', + permissions: ['cs.review', 'cs.credit', 'admin.statement', 'staff.plane'], + }, + sales: { + title: 'Sales', + permissions: ['sales.review', 'sales.quote', 'admin.statement', 'staff.plane'], + }, + accounting: { + title: 'Accounting', + permissions: ['accounting.review', 'accounting.export', 'admin.statement', 'admin.invoices', 'staff.plane'], + }, + operator: { + title: 'Fleet operator', + permissions: ['fleet.operate'], + }, + viewer: { + title: 'Read-only staff', + permissions: ['cs.review', 'sales.review', 'accounting.review', 'admin.statement', 'admin.reports', 'admin.system'], + }, +}); + +export function expandRoles(roles) { + const set = new Set(); + for (const id of roles || []) { + const def = ROLES[id]; + if (!def) continue; + for (const p of def.permissions) { + if (p === '*') return ['*']; + set.add(p); + } + } + return [...set].sort(); +} + +export function allows(permissions, need) { + if (!need) return true; + const list = permissions || []; + if (list.includes('*')) return true; + return list.includes(need); +} + +export function publicUser(u) { + if (!u) return null; + const roles = [...(u.roles || [])]; + return { + id: u.id, + username: u.username, + name: u.name || u.username, + active: u.active !== false, + roles, + permissions: expandRoles(roles), + createdMs: u.createdMs, + }; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..75d1cbe --- /dev/null +++ b/src/server.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +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 } from './roles.js'; +import { verifyPassword } from './passwords.js'; +import { loadIam, saveIam } from './store.js'; +import { + cookieHeader, + clearCookieHeader, + tokenFromReq, + loginLocked, + loginFail, + loginOk, + clientKey, +} from './sessions.js'; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); +const PORT = Number(process.env.PORT || 3028); +const iam = loadIam(); +saveIam(iam); + +function readBody(req) { + return new Promise((resolve) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if ((req.headers['content-type'] || '').includes('json')) { + try { + resolve(JSON.parse(text || '{}')); + } catch { + resolve({}); + } + return; + } + resolve(Object.fromEntries(new URLSearchParams(text))); + }); + }); +} + +function actorOf(req) { + const tok = tokenFromReq(req); + const s = iam.getSession(tok); + if (!s) return null; + const u = iam.findById(s.userId); + if (!u || !u.active) return null; + return publicUser(u); +} + +function requirePerm(req, res, json, perm) { + const me = actorOf(req); + if (!me) { + json(401, { ok: false, reason: 'not signed in' }); + return null; + } + if (!allows(me.permissions, perm)) { + iam.log(me.username, 'deny', perm, req.url); + saveIam(iam); + json(403, { ok: false, reason: 'missing permission', permission: perm, username: me.username }); + return null; + } + return me; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', `http://127.0.0.1:${PORT}`); + const json = (code, obj) => { + res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(obj)); + }; + try { + if (req.method === 'GET' && url.pathname === '/health') { + return json(200, { ok: true, role: 'verae-staff-iam', users: iam.users.length }); + } + if (req.method === 'GET' && (url.pathname === '/login' || url.pathname === '/index.html' || url.pathname === '/')) { + const me = actorOf(req); + const file = me ? 'app.html' : 'login.html'; + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + return res.end(fs.readFileSync(path.join(PUBLIC, file))); + } + if (req.method === 'GET' && url.pathname === '/roles') { + return json(200, { roles: ROLES, permissions: PERMISSIONS }); + } + if (req.method === 'POST' && url.pathname === '/login') { + const body = await readBody(req); + const username = String(body.username || '').trim(); + const password = String(body.password || ''); + const next = body.next || '/'; + if (loginLocked(req, username)) { + iam.log(username || 'unknown', 'login.lock', clientKey(req)); + saveIam(iam); + return json(429, { error: 'too many login attempts; try again in 10 minutes' }); + } + const user = iam.findByUsername(username); + if (!user || !user.active || !verifyPassword(password, user.passwordHash)) { + loginFail(req, username); + iam.log(username || 'unknown', 'login.fail', username); + saveIam(iam); + if ((req.headers['content-type'] || '').includes('json')) return json(401, { error: 'invalid username or password' }); + res.writeHead(302, { location: '/login?error=1' }); + return res.end(); + } + loginOk(req, username); + const token = iam.issueSession(user.id); + iam.log(username, 'login.ok', username); + saveIam(iam); + const loc = typeof next === 'string' && (next.startsWith('http') || next.startsWith('/')) ? next : '/'; + if ((req.headers['content-type'] || '').includes('json')) { + res.writeHead(200, { + 'content-type': 'application/json', + 'set-cookie': cookieHeader(token), + }); + return res.end(JSON.stringify({ token, user: publicUser(user) })); + } + res.writeHead(302, { 'set-cookie': cookieHeader(token), location: loc }); + return res.end(); + } + if (req.method === 'POST' && url.pathname === '/logout') { + iam.revokeSession(tokenFromReq(req)); + saveIam(iam); + if ((req.headers['content-type'] || '').includes('json')) { + res.writeHead(200, { 'content-type': 'application/json', 'set-cookie': clearCookieHeader() }); + return res.end(JSON.stringify({ ok: true })); + } + res.writeHead(302, { 'set-cookie': clearCookieHeader(), location: '/login' }); + return res.end(); + } + if (req.method === 'GET' && url.pathname === '/check') { + const me = actorOf(req); + if (!me) return json(401, { ok: false, reason: 'not signed in' }); + const need = url.searchParams.get('permission'); + if (need && !allows(me.permissions, need)) { + iam.log(me.username, 'deny', need, 'check'); + saveIam(iam); + return json(403, { ok: false, reason: 'missing permission', permission: need, username: me.username }); + } + return json(200, { ok: true, user: me, permission: need || null }); + } + if (req.method === 'GET' && url.pathname === '/me') { + const me = actorOf(req); + if (!me) return json(401, { ok: false }); + return json(200, { user: me }); + } + if (req.method === 'GET' && url.pathname === '/users') { + const me = requirePerm(req, res, json, 'iam.users.read'); + if (!me) return; + return json(200, { users: iam.users.map(publicUser) }); + } + if (req.method === 'GET' && url.pathname === '/audit') { + const me = requirePerm(req, res, json, 'iam.users.read'); + if (!me) return; + return json(200, { audit: iam.audit.slice(0, 200) }); + } + if (req.method === 'POST' && url.pathname === '/users') { + const me = requirePerm(req, res, json, 'iam.users.write'); + if (!me) return; + const body = await readBody(req); + try { + const user = iam.create({ ...body, actor: me.username }); + saveIam(iam); + return json(201, publicUser(user)); + } catch (err) { + return json(err.status || 400, { error: err.message }); + } + } + const upd = url.pathname.match(/^\/users\/([^/]+)$/); + if (req.method === 'PUT' && upd) { + const me = requirePerm(req, res, json, 'iam.users.write'); + if (!me) return; + const body = await readBody(req); + try { + const user = iam.update(decodeURIComponent(upd[1]), body, me.username); + saveIam(iam); + return json(200, publicUser(user)); + } catch (err) { + return json(err.status || 400, { error: err.message }); + } + } + json(404, { error: 'not found' }); + } catch (err) { + json(500, { error: err.message }); + } +}); + +server.listen(PORT, '0.0.0.0', () => { + process.stdout.write(`verae-staff-iam http://0.0.0.0:${PORT}/ users=${iam.users.length}\n`); +}); diff --git a/src/sessions.js b/src/sessions.js new file mode 100644 index 0000000..0ab0d77 --- /dev/null +++ b/src/sessions.js @@ -0,0 +1,59 @@ +export function cookieHeader(token) { + const ttl = 12 * 60 * 60; + let s = `staff_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${ttl}`; + const domain = process.env.STAFF_COOKIE_DOMAIN; + if (domain) s += `; Domain=${domain}`; + if (process.env.STAFF_COOKIE_SECURE === '1') s += '; Secure'; + return s; +} + +export function clearCookieHeader() { + let s = 'staff_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; + const domain = process.env.STAFF_COOKIE_DOMAIN; + if (domain) s += `; Domain=${domain}`; + return s; +} + +export function tokenFromReq(req) { + const bearer = req.headers?.authorization; + if (bearer?.startsWith('Bearer ')) return bearer.slice(7); + const raw = req.headers?.cookie || ''; + const m = /(?:^|; )staff_session=([^;]+)/.exec(raw); + return m ? m[1] : ''; +} + +const fails = new Map(); + +export function clientKey(req) { + return String(req.headers?.['x-forwarded-for'] || req.socket?.remoteAddress || 'local').split(',')[0].trim(); +} + +function failKey(req, username) { + return `${clientKey(req)}:${String(username || '').toLowerCase()}`; +} + +/** True if this IP+username is currently locked out. */ +export function loginLocked(req, username) { + const k = failKey(req, username); + const s = fails.get(k); + if (!s) return false; + if (Date.now() > s.until) { + fails.delete(k); + return false; + } + return s.n >= 8; +} + +export function loginFail(req, username) { + const k = failKey(req, username); + const now = Date.now(); + let s = fails.get(k); + if (!s || now > s.until) s = { n: 0, until: now + 10 * 60 * 1000 }; + s.n += 1; + fails.set(k, s); + return s.n >= 8; +} + +export function loginOk(req, username) { + fails.delete(failKey(req, username)); +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..0d12560 --- /dev/null +++ b/src/store.js @@ -0,0 +1,201 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { hashPassword } from './passwords.js'; +import { ROLES } from './roles.js'; + +export function storePath() { + return process.env.STAFF_IAM_PATH || path.join(process.env.FLEET_STATE_DIR || process.cwd(), 'data', 'staff-iam.json'); +} + +export function newId() { + return `stu_${randomBytes(6).toString('hex')}`; +} + +export class StaffIam { + constructor() { + /** @type {Array<{id:string,username:string,name:string,passwordHash:string,roles:string[],active:boolean,createdMs:number}>} */ + this.users = []; + /** @type {Array<{t:string,actor:string,action:string,target?:string,detail?:string}>} */ + this.audit = []; + /** @type {Record} */ + this.sessions = {}; + } + + seed() { + if (this.users.length) return this; + const seeds = [ + { + username: process.env.IAM_OWNER_USER || process.env.ADMIN_USER || 'admin', + password: process.env.IAM_OWNER_PASSWORD || process.env.ADMIN_KEY || 'admin-dev-key', + name: 'Owner', + roles: ['owner'], + }, + { + username: process.env.IAM_CS_USER || 'cs', + password: process.env.IAM_CS_PASSWORD || 'cs-dev-key', + name: 'Customer service', + roles: ['cs'], + }, + { + username: process.env.IAM_SALES_USER || 'sales', + password: process.env.IAM_SALES_PASSWORD || 'sales-dev-key', + name: 'Sales', + roles: ['sales'], + }, + { + username: process.env.IAM_ACCT_USER || 'accounting', + password: process.env.IAM_ACCT_PASSWORD || 'acct-dev-key', + name: 'Accounting', + roles: ['accounting'], + }, + { + username: process.env.IAM_OPS_USER || 'operator', + password: process.env.IAM_OPS_PASSWORD || 'fleet-dev-key', + name: 'Fleet operator', + roles: ['operator'], + }, + ]; + for (const s of seeds) { + this.users.push({ + id: newId(), + username: s.username, + name: s.name, + passwordHash: hashPassword(s.password), + roles: s.roles, + active: true, + createdMs: Date.now(), + }); + } + this.log('system', 'seed', null, `${this.users.length} users`); + return this; + } + + log(actor, action, target, detail) { + this.audit.unshift({ + t: new Date().toISOString(), + actor: actor || 'system', + action, + target: target || undefined, + detail: detail || undefined, + }); + this.audit = this.audit.slice(0, 400); + } + + findByUsername(username) { + return this.users.find((u) => u.username === username); + } + + findById(id) { + return this.users.find((u) => u.id === id); + } + + create({ username, password, name, roles, actor }) { + if (!/^[a-zA-Z0-9_.-]+$/.test(username || '')) throw Object.assign(new Error('bad username'), { status: 400 }); + if (!password || String(password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 }); + if (this.findByUsername(username)) throw Object.assign(new Error('username already exists'), { status: 409 }); + const cleanRoles = (roles || []).filter((r) => ROLES[r]); + const user = { + id: newId(), + username, + name: name || username, + passwordHash: hashPassword(password), + roles: cleanRoles.length ? cleanRoles : ['viewer'], + active: true, + createdMs: Date.now(), + }; + this.users.push(user); + this.log(actor, 'user.create', username, user.roles.join(',')); + return user; + } + + update(username, patch, actor) { + const user = this.findByUsername(username); + if (!user) throw Object.assign(new Error('not found'), { status: 404 }); + if (patch.name !== undefined) user.name = String(patch.name); + if (Array.isArray(patch.roles)) { + user.roles = patch.roles.filter((r) => ROLES[r]); + } + if (typeof patch.active === 'boolean') { + if (patch.active === false) { + const owners = this.users.filter((u) => u.active && u.roles.includes('owner')); + if (user.roles.includes('owner') && owners.length <= 1) { + throw Object.assign(new Error('cannot deactivate the last owner'), { status: 400 }); + } + } + user.active = patch.active; + } + if (patch.password) { + if (String(patch.password).length < 8) throw Object.assign(new Error('password must be at least 8 characters'), { status: 400 }); + user.passwordHash = hashPassword(patch.password); + } + this.log(actor, 'user.update', username, JSON.stringify({ roles: user.roles, active: user.active })); + return user; + } + + issueSession(userId, ttlMs = 12 * 60 * 60 * 1000) { + const token = randomBytes(24).toString('hex'); + this.sessions[token] = { userId, exp: Date.now() + ttlMs }; + this.pruneSessions(); + return token; + } + + getSession(token) { + if (!token) return null; + this.pruneSessions(); + const s = this.sessions[token]; + if (!s) return null; + if (s.exp < Date.now()) { + delete this.sessions[token]; + return null; + } + return s; + } + + revokeSession(token) { + if (token) delete this.sessions[token]; + } + + pruneSessions() { + const now = Date.now(); + for (const [k, s] of Object.entries(this.sessions)) { + if (!s || s.exp < now) delete this.sessions[k]; + } + } + + dump() { + this.pruneSessions(); + return { users: this.users, audit: this.audit, sessions: this.sessions }; + } + + load(raw) { + if (!raw || typeof raw !== 'object') return this; + this.users = Array.isArray(raw.users) ? raw.users : []; + this.audit = Array.isArray(raw.audit) ? raw.audit : []; + this.sessions = raw.sessions && typeof raw.sessions === 'object' ? raw.sessions : {}; + this.pruneSessions(); + return this; + } +} + +export function loadIam() { + const iam = new StaffIam(); + const p = storePath(); + if (fs.existsSync(p)) { + try { + iam.load(JSON.parse(fs.readFileSync(p, 'utf8'))); + } catch { + /* empty */ + } + } + if (!iam.users.length) iam.seed(); + return iam; +} + +export function saveIam(iam) { + const p = storePath(); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = `${p}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(iam.dump(), null, 2)); + fs.renameSync(tmp, p); +} diff --git a/test/health.test.js b/test/health.test.js new file mode 100644 index 0000000..b9ffb95 --- /dev/null +++ b/test/health.test.js @@ -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'); + } +}); diff --git a/test/roles.test.js b/test/roles.test.js new file mode 100644 index 0000000..8f9713c --- /dev/null +++ b/test/roles.test.js @@ -0,0 +1,31 @@ +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('sessions persist in dump/load', () => { + const iam = new StaffIam().seed(); + const u = iam.findByUsername('cs'); + const tok = iam.issueSession(u.id); + const raw = iam.dump(); + const b = new StaffIam().load(raw); + assert.equal(b.getSession(tok).userId, u.id); +}); + +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']); +});