screentinker/docs/billing-authz-plan.md
ScreenTinker 677b17028e 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>
2026-07-01 21:16:21 -05:00

7.5 KiB
Raw Blame History

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.

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