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>
7.5 KiB
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
roleforced to'user'(line 63) — everyPLATFORM_ROLES/ELEVATED_ROLEScheck downstream is false. So a token can never passrequirePlatformAdmin; billing is unreachable by any token today. - Routers are partitioned in
config/api-surface.js:PUBLIC_ROUTERS(token + JWT, gated bytokenScopeGateread<write<full),JWT_ONLY_ROUTERS(/api/admin, etc. — tokensjwt.verify-fail → 401), andAGENCY_ROUTERS— an off-ladder capability scope (agencyGate: token must be exactlyscope==='agency', tied to no role, reaches only/api/agency). This #73agencypattern 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: MEDIUM–LARGE.
- 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
usersrow (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:readflag onusers. 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: MEDIUM–HIGH — 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: SMALL–MEDIUM. No migration.
- Changes, all additive and isolated (do NOT touch
PLATFORM_ROLES/requireAuth/shared checks):middleware/apiToken.js— addbillingGate(mirroragencyGate), but allow the JWT platform-admin too:req.viaToken ? req.tokenScope==='billing' : isPlatformRole(req.user.role). ~6 lines, export it.config/api-surface.js— addBILLING_ROUTERS = [{ path:'/api/billing', mod:'./routes/billing' }];server.jsmounts it withbearerAuth + billingGate(mirroring the AGENCY mount) and the bespokeapp.use('/api/billing', requireAuth, …)at server.js:582 is removed. This also brings billing under the firewall-test partition (closes the current gap).routes/tokens.js— add'billing'toSCOPES; 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.routes/billing.js— droprequirePlatformAdminfrom the handler (the mount-levelbillingGatenow authorizes both a billing token and a platform-admin JWT).db/schema.sql— update theapi_tokens.scopecomment to includebilling(doc only; column is free-text TEXT — no migration).
- Tests: billing token → 200; a read/write/full/agency token → 403 (off-ladder,
tokenScopeGatealready 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
billingscope is off the read/write/full ladder, sotokenScopeGaterejects it on every other router —billingGateis 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 (~4–5 files, no migration, one new test file + a firewall-test line).