Enforce granular capabilities across cashup

Backend (server-side enforcement, not just UI):
- auth.js: read caps from JWT; hasCap() + requireCap() helpers;
  legacy-token fallback (full access minus settings) so existing
  sessions keep working until re-login
- finalise: submit final, delete draft, bulk-finalise, attachments
- reports: multiday report, cash summary, debtors
- floats: float management + safe count
- settings: settings mutations (was is_admin)
- count: draft save, newbook fetch

Frontend:
- can(user, cap) helper; User.caps from /verify
- Nav items, routes and actions (Submit Final, delete, bulk-finalise)
  gated on capabilities; non-finalisers see a draft-only hint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 14:19:30 +00:00
parent 43c332be9b
commit be670f724d
13 changed files with 117 additions and 52 deletions

View file

@ -27,9 +27,39 @@ export async function requireAuth(request, reply) {
}
}
// Capabilities arrive as "<app>:<cap>" strings in the JWT. Store the bare
// cap slugs for this app (e.g. "finalise") plus admin status.
const prefix = `${APP_SLUG}:`
let caps
if (Array.isArray(payload.caps)) {
caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length))
} else {
// Legacy token issued before granular capabilities existed. Reproduce the
// old behaviour: full access except settings (which was is_admin-gated).
// These users get precise capabilities the next time they log in.
caps = ['count', 'finalise', 'reports', 'floats']
}
request.user = {
email: payload.sub,
name: payload.name,
is_admin: payload.is_admin ?? false,
caps,
}
}
// Returns true if the authenticated user holds the given capability.
// Admins implicitly hold every capability.
export function hasCap(request, cap) {
return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true
}
// Fastify preHandler factory — reject the request unless the user holds `cap`.
// Use after requireAuth: { preHandler: [requireAuth, requireCap('finalise')] }
export function requireCap(cap) {
return async (request, reply) => {
if (!hasCap(request, cap)) {
return reply.status(403).send({ error: `Missing capability: ${cap}` })
}
}
}

View file

@ -38,14 +38,14 @@ await app.register(floatRoutes)
await app.register(settingsRoutes)
// File upload for cash up receipt attachments
import { requireAuth } from './auth.js'
import { requireAuth, requireCap } from './auth.js'
import { pool } from './db.js'
import { createWriteStream } from 'fs'
import { mkdir } from 'fs/promises'
import { randomUUID } from 'crypto'
import { extname } from 'path'
app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, async (req, reply) => {
app.post('/api/attachments/upload/:cash_up_id', { preHandler: [requireAuth, requireCap('count')] }, async (req, reply) => {
const cashUpId = parseInt(req.params.cash_up_id)
const { rows } = await pool.query('SELECT id FROM cash_ups WHERE id = $1', [cashUpId])
if (!rows.length) return reply.status(404).send({ error: 'Cash up not found' })
@ -89,7 +89,7 @@ app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, asy
return ins.rows[0]
})
app.delete('/api/attachments/:id', { preHandler: requireAuth }, async (req, reply) => {
app.delete('/api/attachments/:id', { preHandler: [requireAuth, requireCap('count')] }, async (req, reply) => {
const { rows } = await pool.query('SELECT * FROM cash_count_attachments WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, hasCap } from '../auth.js'
export async function cashupRoutes(app) {
app.addHook('preHandler', requireAuth)
@ -38,6 +38,12 @@ export async function cashupRoutes(app) {
if (!session_date) return reply.status(400).send({ error: 'session_date required' })
if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' })
// Draft save needs 'count'; finalising needs 'finalise'.
if (!hasCap(req, 'count')) return reply.status(403).send({ error: 'Missing capability: count' })
if (status === 'final' && !hasCap(req, 'finalise')) {
return reply.status(403).send({ error: 'Missing capability: finalise' })
}
let totalFloat = 0, totalCash = 0
for (const d of denominations) {
if (d.count_type === 'float') totalFloat += parseFloat(d.total_amount || 0)
@ -100,8 +106,9 @@ export async function cashupRoutes(app) {
return { message: status === 'final' ? 'Cash up submitted.' : 'Saved as draft.', cash_up_id: cashUpId }
})
// DELETE /api/cashup/:id
// DELETE /api/cashup/:id (requires cashup:finalise)
app.delete('/api/cashup/:id', async (req, reply) => {
if (!hasCap(req, 'finalise')) return reply.status(403).send({ error: 'Missing capability: finalise' })
const { rows } = await pool.query('SELECT status FROM cash_ups WHERE id = $1', [req.params.id])
if (!rows.length) return reply.status(404).send({ error: 'Not found' })
if (rows[0].status === 'final') return reply.status(409).send({ error: 'Cannot delete a finalised cash up' })
@ -109,8 +116,9 @@ export async function cashupRoutes(app) {
return { message: 'Deleted' }
})
// POST /api/cashup/bulk-finalize
// POST /api/cashup/bulk-finalize (requires cashup:finalise)
app.post('/api/cashup/bulk-finalize', async (req, reply) => {
if (!hasCap(req, 'finalise')) return reply.status(403).send({ error: 'Missing capability: finalise' })
const { ids } = req.body
if (!Array.isArray(ids) || !ids.length) return reply.status(400).send({ error: 'ids required' })

View file

@ -1,8 +1,9 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, requireCap } from '../auth.js'
export async function floatRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('floats'))
// POST /api/floats/save
// body: { count_type, count_date, denominations, receipts?, total_counted, total_receipts?, target_amount?, variance?, notes }

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, requireCap } from '../auth.js'
import {
fetchPaymentsByDate,
calculatePaymentTotals,
@ -9,6 +9,7 @@ import {
export async function newbookRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('count'))
// POST /api/newbook/payments { date: 'YYYY-MM-DD' }
app.post('/api/newbook/payments', async (req, reply) => {

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, requireCap } from '../auth.js'
import {
fetchPaymentsByDateRange,
fetchDailyAuditSummary,
@ -25,6 +25,7 @@ function buildDateRange(startDate, numDays) {
export async function reportRoutes(app) {
app.addHook('preHandler', requireAuth)
app.addHook('preHandler', requireCap('reports'))
// POST /api/reports/multiday { start_date, num_days }
app.post('/api/reports/multiday', async (req, reply) => {

View file

@ -1,5 +1,5 @@
import { pool } from '../db.js'
import { requireAuth } from '../auth.js'
import { requireAuth, hasCap } from '../auth.js'
import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
@ -16,9 +16,9 @@ export async function settingsRoutes(app) {
return Object.fromEntries(rows.map(r => [r.key, r.value]))
})
// PUT /api/settings (admin only)
// PUT /api/settings (requires cashup:settings)
app.put('/api/settings', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Missing capability: settings' })
for (const [key, value] of Object.entries(req.body)) {
if (!ALL_KEYS.includes(key)) continue
await pool.query(
@ -29,16 +29,16 @@ export async function settingsRoutes(app) {
return { message: 'Settings saved.' }
})
// POST /api/settings/test-connection (admin only) — proxies to settings service
// POST /api/settings/test-connection (requires cashup:settings) — proxies to settings service
app.post('/api/settings/test-connection', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Missing capability: settings' })
const result = await testConnection()
return result
})
// POST /api/settings/refresh-gl-accounts (admin only)
// POST /api/settings/refresh-gl-accounts (requires cashup:settings)
app.post('/api/settings/refresh-gl-accounts', async (req, reply) => {
if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' })
if (!hasCap(req, 'settings')) return reply.status(403).send({ error: 'Missing capability: settings' })
let groups
try {