From c7f7079b990dba6ff4bf56ed4d91355b46b90ae1 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 14:19:08 +0000 Subject: [PATCH] Add per-app granular capabilities (RBAC beyond app access) Schema: - app_capabilities: capabilities each app exposes (:) - role_capabilities / user_capabilities: grants via roles and direct - Seed cashup caps (count, finalise, reports, floats, settings) - Non-breaking migration: default Staff role gets all cashup caps except settings (previously only is_admin reached settings) Resolution: - getUserCapabilities(): admins get all; others get union of role + direct grants, as ":" strings - caps[] added to JWT payload (login + register) - /verify returns live capabilities for the requested app (bare slugs) Admin API: - GET /admin/capabilities catalogue - grant/revoke capability on roles and users - roles/users GET responses now include their capabilities Co-Authored-By: Claude Opus 4.8 (1M context) --- src/db.js | 65 +++++++++++++++++++++++++++++++++ src/routes/admin.js | 82 ++++++++++++++++++++++++++++++++++++++++-- src/routes/auth.js | 42 +++++++++++++++++++++- src/routes/register.js | 1 + 4 files changed, 186 insertions(+), 4 deletions(-) diff --git a/src/db.js b/src/db.js index 210aecb..cab33ae 100644 --- a/src/db.js +++ b/src/db.js @@ -79,6 +79,31 @@ export async function initDb() { expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); + + -- Per-app granular capabilities. An app declares the capabilities it exposes + -- (e.g. cashup → finalise, reports). The JWT carries them as ":". + CREATE TABLE IF NOT EXISTS app_capabilities ( + id SERIAL PRIMARY KEY, + app_id INTEGER NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (app_id, slug) + ); + + CREATE TABLE IF NOT EXISTS role_capabilities ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + capability_id INTEGER NOT NULL REFERENCES app_capabilities(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, capability_id) + ); + + CREATE TABLE IF NOT EXISTS user_capabilities ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + capability_id INTEGER NOT NULL REFERENCES app_capabilities(id) ON DELETE CASCADE, + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, capability_id) + ); `) // Migrations for existing installs @@ -115,6 +140,46 @@ export async function initDb() { ON CONFLICT (slug) DO NOTHING `) + // Seed cashup capabilities (idempotent). 'count' is the baseline everyone + // with app access should have; the rest are additive privilege gates. + await pool.query(` + INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) + SELECT a.id, c.slug, c.name, c.description, c.sort_order + FROM apps a + CROSS JOIN (VALUES + ('count', 'Count cash ups', 'Create and edit draft daily cash ups', 1), + ('finalise', 'Finalise cash ups','Submit final, delete drafts, bulk-finalise', 2), + ('reports', 'View reports', 'Weekly / multi-day report, cash summary, debtors', 3), + ('floats', 'Manage floats', 'Float management and safe count', 4), + ('settings', 'Manage settings', 'App settings: Newbook config, GL columns, thresholds', 5) + ) AS c(slug, name, description, sort_order) + WHERE a.slug = 'cashup' + ON CONFLICT (app_id, slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + sort_order = EXCLUDED.sort_order + `) + + // Non-breaking migration: grant the default Staff role every cashup capability + // except 'settings' (previously only is_admin could reach settings). Admins + // implicitly get all capabilities regardless. Only seeds when the Staff role + // has no cashup capabilities yet, so later admin tightening is never undone. + await pool.query(` + INSERT INTO role_capabilities (role_id, capability_id) + SELECT r.id, ac.id + FROM roles r + JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'cashup') + JOIN apps a ON a.id = ac.app_id + WHERE r.slug = 'staff' + AND ac.slug IN ('count', 'finalise', 'reports', 'floats') + AND NOT EXISTS ( + SELECT 1 FROM role_capabilities rc + JOIN app_capabilities ac2 ON ac2.id = rc.capability_id + WHERE rc.role_id = r.id AND ac2.app_id = a.id + ) + ON CONFLICT DO NOTHING + `) + // Seed first admin user if table is empty const { rows } = await pool.query('SELECT COUNT(*) FROM users') if (parseInt(rows[0].count) === 0) { diff --git a/src/routes/admin.js b/src/routes/admin.js index b4083ae..1cb0697 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -26,7 +26,14 @@ export async function adminRoutes(app) { u.workforce_user_id, COALESCE(json_agg(DISTINCT a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs, COALESCE(json_agg(DISTINCT jsonb_build_object('id', r.id, 'name', r.name, 'slug', r.slug)) - FILTER (WHERE r.id IS NOT NULL), '[]') AS roles + FILTER (WHERE r.id IS NOT NULL), '[]') AS roles, + COALESCE(( + SELECT json_agg(a2.slug || ':' || ac.slug) + FROM user_capabilities uc + JOIN app_capabilities ac ON ac.id = uc.capability_id + JOIN apps a2 ON a2.id = ac.app_id + WHERE uc.user_id = u.id + ), '[]') AS capabilities FROM users u LEFT JOIN user_app_perms p ON p.user_id = u.id LEFT JOIN apps a ON a.id = p.app_id @@ -136,7 +143,14 @@ export async function adminRoutes(app) { app.get('/roles', async () => { const { rows } = await pool.query( `SELECT r.id, r.name, r.slug, r.description, r.is_default, r.created_at, - COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs + COALESCE(json_agg(a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs, + COALESCE(( + SELECT json_agg(a2.slug || ':' || ac.slug) + FROM role_capabilities rc + JOIN app_capabilities ac ON ac.id = rc.capability_id + JOIN apps a2 ON a2.id = ac.app_id + WHERE rc.role_id = r.id + ), '[]') AS capabilities FROM roles r LEFT JOIN role_app_perms rap ON rap.role_id = r.id LEFT JOIN apps a ON a.id = rap.app_id @@ -155,7 +169,7 @@ export async function adminRoutes(app) { `INSERT INTO roles (name, slug, description, is_default) VALUES ($1, $2, $3, $4) RETURNING *`, [name, slug, description || null, is_default] ) - return reply.status(201).send({ ...role, app_slugs: [] }) + return reply.status(201).send({ ...role, app_slugs: [], capabilities: [] }) }) app.patch('/roles/:id', async (request, reply) => { @@ -202,6 +216,68 @@ export async function adminRoutes(app) { return reply.status(204).send() }) + // ── Capabilities ───────────────────────────────────────────────────────────── + + // Catalogue of every capability each app exposes, grouped by app slug. + app.get('/capabilities', async () => { + const { rows } = await pool.query( + `SELECT a.slug AS app_slug, ac.slug, ac.name, ac.description, ac.sort_order + FROM app_capabilities ac + JOIN apps a ON a.id = ac.app_id + WHERE a.active = true + ORDER BY a.slug, ac.sort_order` + ) + return rows + }) + + async function capIdBySlug(appSlug, capSlug) { + const { rows: [c] } = await pool.query( + `SELECT ac.id FROM app_capabilities ac + JOIN apps a ON a.id = ac.app_id + WHERE a.slug = $1 AND ac.slug = $2`, + [appSlug, capSlug] + ) + return c?.id ?? null + } + + app.post('/roles/:roleId/capabilities/:appSlug/:capSlug', async (request, reply) => { + const { roleId, appSlug, capSlug } = request.params + const capId = await capIdBySlug(appSlug, capSlug) + if (!capId) return reply.status(404).send({ error: 'Capability not found' }) + await pool.query( + 'INSERT INTO role_capabilities (role_id, capability_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [roleId, capId] + ) + return { ok: true } + }) + + app.delete('/roles/:roleId/capabilities/:appSlug/:capSlug', async (request, reply) => { + const { roleId, appSlug, capSlug } = request.params + const capId = await capIdBySlug(appSlug, capSlug) + if (!capId) return reply.status(404).send({ error: 'Capability not found' }) + await pool.query('DELETE FROM role_capabilities WHERE role_id = $1 AND capability_id = $2', [roleId, capId]) + return reply.status(204).send() + }) + + app.post('/users/:userId/capabilities/:appSlug/:capSlug', async (request, reply) => { + const { userId, appSlug, capSlug } = request.params + const capId = await capIdBySlug(appSlug, capSlug) + if (!capId) return reply.status(404).send({ error: 'Capability not found' }) + await pool.query( + 'INSERT INTO user_capabilities (user_id, capability_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [userId, capId] + ) + return { ok: true } + }) + + app.delete('/users/:userId/capabilities/:appSlug/:capSlug', async (request, reply) => { + const { userId, appSlug, capSlug } = request.params + const capId = await capIdBySlug(appSlug, capSlug) + if (!capId) return reply.status(404).send({ error: 'Capability not found' }) + await pool.query('DELETE FROM user_capabilities WHERE user_id = $1 AND capability_id = $2', [userId, capId]) + return reply.status(204).send() + }) + // ── User roles ───────────────────────────────────────────────────────────── app.post('/users/:userId/roles/:roleId', async (request, reply) => { diff --git a/src/routes/auth.js b/src/routes/auth.js index d519271..991442a 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -21,6 +21,35 @@ export function cookieOpts(request, clear = false) { } } +// Resolve a user's granular capabilities as ":" strings. +// Admins implicitly hold every capability of every active app; everyone else +// gets the union of capabilities granted via their roles and direct grants. +export async function getUserCapabilities(userId, isAdmin) { + const { rows } = isAdmin + ? await pool.query( + `SELECT a.slug AS app_slug, ac.slug AS cap_slug + FROM app_capabilities ac + JOIN apps a ON a.id = ac.app_id + WHERE a.active = true` + ) + : await pool.query( + `SELECT DISTINCT a.slug AS app_slug, ac.slug AS cap_slug + FROM app_capabilities ac + JOIN apps a ON a.id = ac.app_id + WHERE a.active = true + AND ( + EXISTS (SELECT 1 FROM user_capabilities uc WHERE uc.user_id = $1 AND uc.capability_id = ac.id) + OR EXISTS ( + SELECT 1 FROM user_roles ur + JOIN role_capabilities rc ON rc.role_id = ur.role_id + WHERE ur.user_id = $1 AND rc.capability_id = ac.id + ) + )`, + [userId] + ) + return rows.map(r => `${r.app_slug}:${r.cap_slug}`) +} + export async function getUserWithApps(userId) { const { rows: [user] } = await pool.query( 'SELECT id, email, name, is_admin, offsite_allowed FROM users WHERE id = $1 AND active = true', @@ -53,7 +82,8 @@ export async function getUserWithApps(userId) { [userId] ) - return { ...user, apps } + const caps = await getUserCapabilities(userId, user.is_admin) + return { ...user, apps, caps } } export async function authRoutes(app) { @@ -79,6 +109,7 @@ export async function authRoutes(app) { is_admin: full.is_admin, offsite_allowed: full.offsite_allowed, apps: full.apps.map(a => a.slug), + caps: full.caps, }) reply.setCookie('hnf_session', token, cookieOpts(request)) @@ -147,12 +178,21 @@ export async function authRoutes(app) { } } + // Live capability resolution — reflects grants/revocations without re-login. + // When an app is specified, return only that app's capabilities (bare slugs, + // e.g. "finalise"); otherwise return the full ":" list. + const allCaps = await getUserCapabilities(user.id, user.is_admin) + const caps = appSlug + ? allCaps.filter(c => c.startsWith(`${appSlug}:`)).map(c => c.slice(appSlug.length + 1)) + : allCaps + return { user_id: user.id, email: user.email, name: user.name, is_admin: user.is_admin, app: appSlug || null, + caps, } }) } diff --git a/src/routes/register.js b/src/routes/register.js index 4af5e16..4d3b76f 100644 --- a/src/routes/register.js +++ b/src/routes/register.js @@ -162,6 +162,7 @@ export async function registerRoutes(app) { is_admin: full.is_admin, offsite_allowed: full.offsite_allowed, apps: full.apps.map(a => a.slug), + caps: full.caps, }) reply.setCookie('hnf_session', token, cookieOpts(request)) return full