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

114 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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