mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Least-privilege way to read GET /api/billing/usage without requiring platform admin. Additive + isolated: reuses the existing api_tokens scope system (the off-ladder 'agency' scope is the precedent) and does NOT touch the shared role/permission checks other endpoints rely on. - New off-ladder scope 'billing:read' (routes/tokens.js SCOPES). Like 'agency' it is NOT on the read<write<full ladder, so tokenScopeGate rejects a billing token on every PUBLIC_ROUTER and JWT-only routers reject any st_ token -> the scope grants billing-read and NOTHING else. - DUAL-PATH gate requireBillingRead (middleware/apiToken.js), written as an EXPLICIT OR: authorize if (billing:read token) OR (platform-admin session). Admins keep read access but are NOT required to; the token path doesn't lock out admins or vice versa. Billing route now mounted with bearerAuth (token OR JWT front door) + requireBillingRead (was requireAuth + requirePlatformAdmin). - MINTING is platform-admin only (stricter than read/write/full/agency, which any workspace member may mint) since a billing:read token grants GLOBAL billing-read. Note: no finer "owner" tier exists here (#14 collapsed superadmin->platform_admin), so PLATFORM_ROLES is the top level required. Tests (5, test/billing-authz.test.js): dual-path positive (token AND admin session both 200) + negative (user 403 / anon 401); scope isolation (billing token 403 on /api/devices, 401 on /api/admin; read token 200 on devices but 403 on billing); minting owner-only (user + ordinary-admin 403, platform-admin 201); revocation -> 401. Existing token firewall/partition suite (api.test.js) + billing-endpoint tests unchanged & green. Reused the exact SHA-256 token-verification path (no bcrypt/new mechanism). Suite 306/306. NOTE: spec described bcrypt + JSON `scopes` + an analytics:read precedent; this codebase actually uses SHA-256 + a single `scope` TEXT column + 'agency' as the off-ladder precedent. Implemented faithfully to the real system. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 lines
6.1 KiB
JavaScript
101 lines
6.1 KiB
JavaScript
'use strict';
|
|
|
|
// #146 Option C — billing:read scoped token authz. Booted server + JWT + DB access.
|
|
// Covers the DUAL PATH (token OR admin session, both directions), SCOPE ISOLATION (a
|
|
// billing token grants billing-read and nothing else), OWNER-ONLY minting, revocation,
|
|
// and a regression that ordinary token minting is unchanged.
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { spawn } = require('node:child_process');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const PORT = 4011;
|
|
const BASE = `http://127.0.0.1:${PORT}`;
|
|
const DATA_DIR = path.join(os.tmpdir(), 'st-billauthz-' + crypto.randomBytes(4).toString('hex'));
|
|
let proc, db;
|
|
|
|
const reg = (o) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o) });
|
|
const jwtHdr = (t) => ({ headers: { Authorization: 'Bearer ' + t } });
|
|
const post = (t, o) => ({ method: 'POST', headers: { Authorization: 'Bearer ' + t, 'Content-Type': 'application/json' }, body: JSON.stringify(o) });
|
|
async function register(email) {
|
|
return (await (await fetch(BASE + '/api/auth/register', reg({ email, password: 'Passw0rd123' }))).json()).token;
|
|
}
|
|
const setRole = (email, role) => db.prepare('UPDATE users SET role = ? WHERE email = ?').run(role, email);
|
|
|
|
let adminJwt, userJwt, billingToken, billingTokenId, readToken;
|
|
|
|
before(async () => {
|
|
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-billauthz.log'), 'w');
|
|
proc = spawn('node', ['server.js'], {
|
|
cwd: path.join(__dirname, '..'),
|
|
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
|
stdio: ['ignore', logFd, logFd],
|
|
});
|
|
let up = false;
|
|
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await new Promise(r => setTimeout(r, 250)); }
|
|
if (!up) throw new Error('server did not boot');
|
|
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
|
|
|
|
const adminEmail = 'adm' + crypto.randomBytes(4).toString('hex') + '@x.local';
|
|
const userEmail = 'usr' + crypto.randomBytes(4).toString('hex') + '@x.local';
|
|
adminJwt = await register(adminEmail);
|
|
userJwt = await register(userEmail);
|
|
setRole(adminEmail, 'platform_admin'); // role is read from DB per request
|
|
|
|
// platform-admin mints a billing:read token; a normal user mints an ordinary read token.
|
|
const minted = await (await fetch(BASE + '/api/tokens', post(adminJwt, { name: 'invoice-bot', scope: 'billing:read' }))).json();
|
|
billingToken = minted.token; billingTokenId = minted.id;
|
|
readToken = (await (await fetch(BASE + '/api/tokens', post(userJwt, { name: 'reader', scope: 'read' }))).json()).token;
|
|
});
|
|
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
|
|
const S = (r) => r.status;
|
|
|
|
test('DUAL PATH positive: a billing:read token AND an admin session each read billing', async () => {
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))), 200, 'billing:read token can read billing');
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(adminJwt))), 200, 'platform-admin session can read billing (not required to use a token)');
|
|
// both return the real report shape
|
|
const viaToken = await (await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))).json();
|
|
assert.equal(typeof viaToken.billable_screens, 'number');
|
|
});
|
|
|
|
test('DUAL PATH negative: non-admin session and anonymous are refused', async () => {
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(userJwt))), 403, 'ordinary user session denied');
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage')), 401, 'anonymous denied');
|
|
});
|
|
|
|
test('SCOPE ISOLATION: a billing:read token grants billing-read and NOTHING else', async () => {
|
|
// off the read/write/full ladder -> tokenScopeGate rejects it on a normal public router
|
|
assert.equal(S(await fetch(BASE + '/api/devices', jwtHdr(billingToken))), 403, 'billing token cannot read devices');
|
|
// and JWT-only routers reject any st_ token outright
|
|
assert.equal(S(await fetch(BASE + '/api/admin/orgs', jwtHdr(billingToken))), 401, 'billing token cannot reach admin');
|
|
// an ordinary read token can read devices (proves the 403 above is scope isolation, not a broken token)
|
|
assert.equal(S(await fetch(BASE + '/api/devices', jwtHdr(readToken))), 200, 'ordinary read token still reads devices');
|
|
// ...but the ordinary read token CANNOT read billing (isolation from the other side)
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(readToken))), 403, 'read token cannot read billing');
|
|
});
|
|
|
|
test('MINTING is platform-admin only (owner-tier); ordinary admin and user cannot', async () => {
|
|
// ordinary user
|
|
assert.equal(S(await fetch(BASE + '/api/tokens', post(userJwt, { name: 'x', scope: 'billing:read' }))), 403, 'user cannot mint');
|
|
// ordinary admin (ELEVATED but not PLATFORM) also cannot
|
|
const aEmail = 'ord' + crypto.randomBytes(4).toString('hex') + '@x.local';
|
|
const aJwt = await register(aEmail); setRole(aEmail, 'admin');
|
|
assert.equal(S(await fetch(BASE + '/api/tokens', post(aJwt, { name: 'x', scope: 'billing:read' }))), 403, 'ordinary admin cannot mint');
|
|
// platform-admin can (already used in setup) — and an ordinary read token still mints fine (regression)
|
|
assert.equal(S(await fetch(BASE + '/api/tokens', post(adminJwt, { name: 'ok', scope: 'billing:read' }))), 201, 'platform-admin can mint');
|
|
assert.equal(S(await fetch(BASE + '/api/tokens', post(userJwt, { name: 'r', scope: 'read' }))), 201, 'ordinary token minting unchanged');
|
|
});
|
|
|
|
test('REVOCATION: a revoked billing:read token is refused', async () => {
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))), 200, 'valid before revoke');
|
|
const del = await fetch(BASE + '/api/tokens/' + billingTokenId, { method: 'DELETE', ...jwtHdr(adminJwt) });
|
|
assert.ok(del.status === 200 || del.status === 204, 'revoke succeeded');
|
|
assert.equal(S(await fetch(BASE + '/api/billing/usage', jwtHdr(billingToken))), 401, 'revoked token refused');
|
|
});
|