From 677b17028e087fd45b03e23534cbe91d11fec25e Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 1 Jul 2026 21:16:21 -0500 Subject: [PATCH] =?UTF-8?q?feat(#146):=20billing:read=20scoped=20token=20?= =?UTF-8?q?=E2=80=94=20dual-path=20auth=20for=20the=20Usage=20Report=20(Op?= =?UTF-8?q?tion=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- docs/billing-authz-plan.md | 113 ++++++++++++++++++++++++++++++ docs/billing.md | 9 ++- server/db/schema.sql | 2 +- server/middleware/apiToken.js | 19 ++++- server/routes/billing.js | 8 ++- server/routes/tokens.js | 14 +++- server/server.js | 11 +-- server/test/billing-authz.test.js | 100 ++++++++++++++++++++++++++ 8 files changed, 260 insertions(+), 16 deletions(-) create mode 100644 docs/billing-authz-plan.md create mode 100644 server/test/billing-authz.test.js diff --git a/docs/billing-authz-plan.md b/docs/billing-authz-plan.md new file mode 100644 index 0000000..a2903c3 --- /dev/null +++ b/docs/billing-authz-plan.md @@ -0,0 +1,113 @@ +# Billing-read authorization — findings, options, recommendation + +**Status: PLAN. No code changed.** Goal: a least-privilege way to read `GET /api/billing/usage` +that does NOT require platform-admin, while platform-admin can still read it. + +--- + +## Phase 0 — how authz actually works here + +**1. Roles = a fixed, hardcoded enum on `users.role`.** There is NO role→permission mapping. +Authz is `Array.includes(role)` against hardcoded sets in `middleware/auth.js`: +`PLATFORM_ROLES = ['superadmin','platform_admin']`, `ELEVATED_ROLES = ['admin','superadmin', +'platform_admin']`, `PLATFORM_STAFF = [...,'platform_operator']`. Guards are hardcoded +functions: `requireAuth`, `requireAdmin`, `requireSuperAdmin` (`requirePlatformAdmin` is an +alias). The enum is threaded through **~20 server files** plus the frontend role dropdown +(`PLATFORM_ROLE_OPTIONS` in `frontend/js/views/admin.js`) and the #14 role-normalization +migration. Adding a role is a wide change. + +**2. The authz seam is per-route middleware, not centralized.** Billing today: +`server.js:582 → app.use('/api/billing', requireAuth, require('./routes/billing'))`, and +`routes/billing.js` gates the handler with `requirePlatformAdmin`. Other endpoints declare +their guard at mount or per-handler. **Note:** this billing mount is *bespoke* — it is NOT in +`config/api-surface.js` (the partition source of truth) and is therefore **not covered by the +firewall test** (`test/api.test.js`). Fixing that is a side-benefit of Option C. + +**3. Identity: JWT sessions AND scoped API tokens.** `middleware/apiToken.js` implements a +`Bearer st_…` token front door (`api_tokens` table, SHA-256 hash, `scope` column). Its +security model is the important part: + - A token authenticates **as its owner but with `role` forced to `'user'`** (line 63) — + every `PLATFORM_ROLES`/`ELEVATED_ROLES` check downstream is false. So a token can never + pass `requirePlatformAdmin`; **billing is unreachable by any token today.** + - Routers are partitioned in `config/api-surface.js`: `PUBLIC_ROUTERS` (token + JWT, gated + by `tokenScopeGate` read { +// #146 Option C — DUAL PATH: authorized by a 'billing:read' scoped API token OR a +// platform-admin session (requireBillingRead, explicit OR). Admins keep read access but a +// least-privilege token is the intended consumer (tooling / invoice-time pulls / §4.2). +router.get('/usage', requireBillingRead, (req, res) => { try { res.json(billing.buildUsageReport(req.query.month)); } catch (e) { diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 2e04030..6c12985 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -8,10 +8,12 @@ const { db } = require('../db/database'); const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken'); const { accessContext } = require('../lib/tenancy'); const { isZonedPlaylist } = require('../lib/agency-targets'); // #73: full-screen-only guardrail +const { isPlatformRole } = require('../middleware/auth'); // #146: billing:read mint gate // #73: 'agency' is OFF the read/write/full ladder (not in apiToken.js SCOPE_RANK), so a // tokenScopeGate-mounted router rejects it; it reaches only the AGENCY_ROUTER via agencyGate. -const SCOPES = ['read', 'write', 'full', 'agency']; +// #146: 'billing:read' is likewise off-ladder — reaches only /api/billing via requireBillingRead. +const SCOPES = ['read', 'write', 'full', 'agency', 'billing:read']; // List the caller's tokens in the active workspace. Never returns the secret/hash. router.get('/', (req, res) => { @@ -35,7 +37,15 @@ router.post('/', (req, res) => { const scope = req.body.scope || 'read'; if (!name) return res.status(400).json({ error: 'name is required' }); if (name.length > 100) return res.status(400).json({ error: 'name too long' }); - if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write', 'full' or 'agency'" }); + if (!SCOPES.includes(scope)) return res.status(400).json({ error: "scope must be 'read', 'write', 'full', 'agency' or 'billing:read'" }); + // #146 BILLING: a billing:read token grants GLOBAL billing-read, so minting it is + // PLATFORM-ADMIN ONLY — stricter than read/write/full/agency, which any workspace member + // may mint. The privilege is concentrated at ISSUANCE; the token then carries only the + // narrow read. NOTE: there is no finer "owner" tier than platform_admin here — #14 + // collapsed legacy superadmin → platform_admin, so PLATFORM_ROLES is the top level. + if (scope === 'billing:read' && !isPlatformRole(req.user.role)) { + return res.status(403).json({ error: 'only a platform admin can mint a billing:read token' }); + } // The token runs with platform powers stripped (role forced to 'user'), so it must // bind to a workspace the owner reaches via membership/org - not platform act-as - // else apiTokenAuth+resolveTenancy would land it in no workspace at use time. diff --git a/server/server.js b/server/server.js index b02fb65..bc4e094 100644 --- a/server/server.js +++ b/server/server.js @@ -575,11 +575,12 @@ app.get('/api/version', (req, res) => { // Public status page app.use('/api/status', require('./routes/status')); -// #146 BILLING: admin-gated Usage Report on its OWN route (NOT part of /api/status — -// billing is revenue data, admin-only, and a heavier aggregate than the hot status path). -// JWT-only (no tenancy — platform-global); requireAuth populates req.user, then the route's -// requirePlatformAdmin gates on role. -app.use('/api/billing', requireAuth, require('./routes/billing')); +// #146 BILLING: Usage Report on its OWN route (NOT part of /api/status — billing is revenue +// data and a heavier aggregate than the hot status path). bearerAuth is the dual front door: +// a 'billing:read' API token (Bearer st_...) OR a JWT session both reach it; the route's +// requireBillingRead then authorizes a billing:read token OR a platform-admin session. +// No tenancy — billing is platform-global. +app.use('/api/billing', bearerAuth, require('./routes/billing')); // Activity logging middleware now mounted earlier (just before the workspace // route block) - leaving this comment here as a breadcrumb for the move. diff --git a/server/test/billing-authz.test.js b/server/test/billing-authz.test.js new file mode 100644 index 0000000..9a7fdf9 --- /dev/null +++ b/server/test/billing-authz.test.js @@ -0,0 +1,100 @@ +'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'); +});