feat(#146): billing:read scoped token — dual-path auth for the Usage Report (Option C)

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>
This commit is contained in:
ScreenTinker 2026-07-01 21:16:21 -05:00
parent 977407ce99
commit 677b17028e
8 changed files with 260 additions and 16 deletions

113
docs/billing-authz-plan.md Normal file
View file

@ -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<write<full), `JWT_ONLY_ROUTERS` (`/api/admin`, etc. tokens
`jwt.verify`-fail → 401), and **`AGENCY_ROUTERS`** — an **off-ladder capability scope**
(`agencyGate`: token must be exactly `scope==='agency'`, tied to no role, reaches only
`/api/agency`). This #73 `agency` pattern is a working precedent for exactly what we want.
- Token creation (`routes/tokens.js`) is JWT-only, workspace-scoped; `SCOPES =
['read','write','full','agency']`; **any workspace member can mint** read/write/full
tokens for their workspace.
**4. Smallest change that grants ONLY billing-read:** a new off-ladder token scope
`billing`, mirroring `agency` — additive, isolated from the shared role checks.
---
## The three options
### A. New ROLE (`billing_viewer`)
Add a role to the enum and let the billing route accept it. **Effort: MEDIUMLARGE.**
- Touches: `middleware/auth.js` (role set + a guard), `routes/billing.js`, the frontend role
dropdown (`PLATFORM_ROLE_OPTIONS`), the #14 role-normalization migration/comments, and an
audit of the ~20 files that assume the closed role set. **Migration:** likely (role
normalization). **Tests:** new guard + regression across role checks.
- **Blast radius: HIGH** — modifies the shared role model every endpoint depends on, right
before release. And a role lives on a *human* `users` row (one role column), so it doesn't
cleanly serve the real consumer (tooling / invoice-time pulls) and is coarse to revoke.
- Least privilege: mediocre (a human login, not a scoped credential).
### B. New PERMISSION / CAPABILITY (`billing:read`)
Gate billing on a permission granted independently of role. **Effort: LARGE.**
- **There is no permission seam to hang this on** — no permissions table, no role→permission
map. Option B means *introducing* one (table + checker) or faking it with a bespoke
`billing:read` flag on `users`. Either way it adds a new concept to the shared auth path.
**Migration:** yes (new table/column). **Tests:** a whole new permission surface.
- **Blast radius: MEDIUMHIGH** — new seam in shared auth; not additive/isolated.
- Least privilege: good in principle, but the effort/risk is disproportionate for one route.
### C. Dedicated scoped BILLING TOKEN ✅ RECOMMENDED
A revocable, read-only `billing`-scoped API token that authorizes ONLY the billing route —
mirroring the existing off-ladder `agency` scope (#73). **Effort: SMALLMEDIUM. No migration.**
- Changes, all **additive and isolated** (do NOT touch `PLATFORM_ROLES`/`requireAuth`/shared
checks):
1. `middleware/apiToken.js` — add `billingGate` (mirror `agencyGate`), but allow the JWT
platform-admin too: `req.viaToken ? req.tokenScope==='billing' : isPlatformRole(req.user.role)`. ~6 lines, export it.
2. `config/api-surface.js` — add `BILLING_ROUTERS = [{ path:'/api/billing', mod:'./routes/billing' }]`;
`server.js` mounts it with `bearerAuth + billingGate` (mirroring the AGENCY mount) and the
bespoke `app.use('/api/billing', requireAuth, …)` at server.js:582 is removed. This also
**brings billing under the firewall-test partition** (closes the current gap).
3. `routes/tokens.js` — add `'billing'` to `SCOPES`; because a billing token grants
**global** billing-read, gate its creation on platform-admin:
`if (scope==='billing' && !isPlatformRole(req.user.role)) return 403`. ~3 lines.
4. `routes/billing.js` — drop `requirePlatformAdmin` from the handler (the mount-level
`billingGate` now authorizes both a billing token and a platform-admin JWT).
5. `db/schema.sql` — update the `api_tokens.scope` comment to include `billing` (doc only;
column is free-text TEXT — no migration).
- **Tests:** billing token → 200; a read/write/full/agency token → 403 (off-ladder,
`tokenScopeGate` already rejects it everywhere else); platform-admin JWT → 200; non-admin
JWT → 403; anon → 401; a non-platform-admin cannot MINT a billing token (403); firewall
partition test extended.
- **Blast radius: LOW / isolated.** The `billing` scope is off the read/write/full ladder, so
`tokenScopeGate` rejects it on every other router — `billingGate` is its only door. Nothing
the rest of the app depends on is modified.
- **Least privilege: EXCELLENT.** Grants billing-read and nothing else; tied to no human role;
**revocable** (`revoked_at`); minted only by platform-admin. Fits the real consumer —
tooling / invoice-time pulls / the agreement's §4.2 verification access.
- Platform-admin still reads billing via the JWT branch of `billingGate` — allowed, not required.
**Two nuances to document when built:** (a) a billing token's `workspace_id` binding is
*vestigial* — billing is platform-global, so the read ignores it (unlike agency's per-target
binding); (b) if per-tenant billing ever lands, a billing token could then be workspace-scoped.
---
## Recommendation: **Option C**
It is the only option that is simultaneously least-privilege (billing-read only, revocable,
no human role), lowest effort (no migration; reuses the proven #73 `agency` pattern), and —
decisively for a pre-release change — **additive and isolated**: it never touches the shared
role/permission checks every other endpoint depends on. Options A and B both modify or extend
the shared auth path, which is exactly the destabilization we want to avoid before beta7 ships.
**Next step:** Dan picks A / B / C. If C, the build is a separate task (~45 files, no
migration, one new test file + a firewall-test line).

View file

@ -48,9 +48,12 @@ the rate table by workspace/org). All values are config-driven: `BILLING_HOURS_P
## API (admin-only, standalone route)
`GET /api/billing/usage?month=YYYY-MM` (default: current month) — platform-admin gated,
mounted separately from `/api/status` (billing is revenue data and a heavier aggregate; it
must not touch the hot status path). Reads the rollup only. Returns:
`GET /api/billing/usage?month=YYYY-MM` (default: current month) — readable via a
**`billing:read` scoped API token** (owner/platform-admin-minted, revocable, grants
billing-read ONLY) **OR** a platform-admin session; a `billing:read` token is the intended
consumer (tooling / invoice-time pulls / §4.2 verification) and cannot reach any other
endpoint. Mounted separately from `/api/status` (billing is revenue data and a heavier
aggregate; it must not touch the hot status path). Reads the rollup only. Returns:
`{ month, days_in_month, days_elapsed, provisioned_screens, billable_screens,
billable_screens_final?, is_final, tier, rate_usd, cost_usd, daily:[{day, active_screen_days}] }`.

View file

@ -546,7 +546,7 @@ CREATE TABLE IF NOT EXISTS api_tokens (
name TEXT NOT NULL, -- user-given label
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency'
scope TEXT NOT NULL DEFAULT 'read', -- 'read' | 'write' | 'full' | 'agency' | 'billing:read'
auto_publish INTEGER NOT NULL DEFAULT 0, -- #73: agency only. 0 = items land DRAFT (default, fail-safe); 1 = admin opted this agency out of approval
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
last_used_at INTEGER,

View file

@ -14,7 +14,7 @@
const crypto = require('crypto');
const { db } = require('../db/database');
const { requireAuth } = require('./auth');
const { requireAuth, isPlatformRole } = require('./auth');
const TOKEN_PREFIX = 'st_';
@ -129,7 +129,22 @@ function agencyGate(req, res, next) {
next();
}
// #146 BILLING: dual-path gate for GET /api/billing/usage. Authorize if EITHER path holds,
// written as an EXPLICIT OR so neither can lock out the other:
// (a) a valid API token whose scope is 'billing:read' (tooling / invoice-time pulls), OR
// (b) an authenticated platform-admin SESSION (no token) — admins keep read access but
// are NOT required.
// 'billing:read' is OFF the read/write/full ladder (not in SCOPE_RANK), so tokenScopeGate
// rejects a billing token on every PUBLIC_ROUTER and JWT-only routers reject any st_ token
// (jwt.verify → 401): the scope grants billing-read and NOTHING else.
function requireBillingRead(req, res, next) {
const viaBillingToken = req.viaToken && req.tokenScope === 'billing:read';
const viaAdminSession = !req.viaToken && req.user && isPlatformRole(req.user.role);
if (viaBillingToken || viaAdminSession) return next();
return res.status(403).json({ error: 'billing read requires a billing:read token or a platform-admin session' });
}
module.exports = {
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate,
bearerAuth, apiTokenAuth, tokenScopeGate, requireScope, agencyGate, requireBillingRead,
hashToken, generateToken, displayPrefix, TOKEN_PREFIX,
};

View file

@ -7,12 +7,14 @@
const express = require('express');
const router = express.Router();
const { requirePlatformAdmin } = require('../middleware/auth');
const { requireBillingRead } = require('../middleware/apiToken');
const billing = require('../lib/billing');
// GET /api/billing/usage?month=YYYY-MM (default: current month)
// Admin/platform-role gated with the SAME authz as other admin endpoints.
router.get('/usage', requirePlatformAdmin, (req, res) => {
// #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) {

View file

@ -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.

View file

@ -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.

View file

@ -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');
});