Add per-app granular capabilities (RBAC beyond app access)
Schema: - app_capabilities: capabilities each app exposes (<app>:<slug>) - 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 "<app>:<cap>" 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) <noreply@anthropic.com>
This commit is contained in:
parent
fdbc4df29c
commit
c7f7079b99
4 changed files with 186 additions and 4 deletions
65
src/db.js
65
src/db.js
|
|
@ -79,6 +79,31 @@ export async function initDb() {
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 "<app>:<slug>".
|
||||||
|
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
|
// Migrations for existing installs
|
||||||
|
|
@ -115,6 +140,46 @@ export async function initDb() {
|
||||||
ON CONFLICT (slug) DO NOTHING
|
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
|
// Seed first admin user if table is empty
|
||||||
const { rows } = await pool.query('SELECT COUNT(*) FROM users')
|
const { rows } = await pool.query('SELECT COUNT(*) FROM users')
|
||||||
if (parseInt(rows[0].count) === 0) {
|
if (parseInt(rows[0].count) === 0) {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,14 @@ export async function adminRoutes(app) {
|
||||||
u.workforce_user_id,
|
u.workforce_user_id,
|
||||||
COALESCE(json_agg(DISTINCT a.slug) FILTER (WHERE a.slug IS NOT NULL), '[]') AS app_slugs,
|
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))
|
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
|
FROM users u
|
||||||
LEFT JOIN user_app_perms p ON p.user_id = u.id
|
LEFT JOIN user_app_perms p ON p.user_id = u.id
|
||||||
LEFT JOIN apps a ON a.id = p.app_id
|
LEFT JOIN apps a ON a.id = p.app_id
|
||||||
|
|
@ -136,7 +143,14 @@ export async function adminRoutes(app) {
|
||||||
app.get('/roles', async () => {
|
app.get('/roles', async () => {
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT r.id, r.name, r.slug, r.description, r.is_default, r.created_at,
|
`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
|
FROM roles r
|
||||||
LEFT JOIN role_app_perms rap ON rap.role_id = r.id
|
LEFT JOIN role_app_perms rap ON rap.role_id = r.id
|
||||||
LEFT JOIN apps a ON a.id = rap.app_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 *`,
|
`INSERT INTO roles (name, slug, description, is_default) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||||
[name, slug, description || null, is_default]
|
[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) => {
|
app.patch('/roles/:id', async (request, reply) => {
|
||||||
|
|
@ -202,6 +216,68 @@ export async function adminRoutes(app) {
|
||||||
return reply.status(204).send()
|
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 ─────────────────────────────────────────────────────────────
|
// ── User roles ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.post('/users/:userId/roles/:roleId', async (request, reply) => {
|
app.post('/users/:userId/roles/:roleId', async (request, reply) => {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,35 @@ export function cookieOpts(request, clear = false) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a user's granular capabilities as "<app_slug>:<cap_slug>" 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) {
|
export async function getUserWithApps(userId) {
|
||||||
const { rows: [user] } = await pool.query(
|
const { rows: [user] } = await pool.query(
|
||||||
'SELECT id, email, name, is_admin, offsite_allowed FROM users WHERE id = $1 AND active = true',
|
'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]
|
[userId]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { ...user, apps }
|
const caps = await getUserCapabilities(userId, user.is_admin)
|
||||||
|
return { ...user, apps, caps }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authRoutes(app) {
|
export async function authRoutes(app) {
|
||||||
|
|
@ -79,6 +109,7 @@ export async function authRoutes(app) {
|
||||||
is_admin: full.is_admin,
|
is_admin: full.is_admin,
|
||||||
offsite_allowed: full.offsite_allowed,
|
offsite_allowed: full.offsite_allowed,
|
||||||
apps: full.apps.map(a => a.slug),
|
apps: full.apps.map(a => a.slug),
|
||||||
|
caps: full.caps,
|
||||||
})
|
})
|
||||||
|
|
||||||
reply.setCookie('hnf_session', token, cookieOpts(request))
|
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 "<app>:<cap>" 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 {
|
return {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
is_admin: user.is_admin,
|
is_admin: user.is_admin,
|
||||||
app: appSlug || null,
|
app: appSlug || null,
|
||||||
|
caps,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ export async function registerRoutes(app) {
|
||||||
is_admin: full.is_admin,
|
is_admin: full.is_admin,
|
||||||
offsite_allowed: full.offsite_allowed,
|
offsite_allowed: full.offsite_allowed,
|
||||||
apps: full.apps.map(a => a.slug),
|
apps: full.apps.map(a => a.slug),
|
||||||
|
caps: full.caps,
|
||||||
})
|
})
|
||||||
reply.setCookie('hnf_session', token, cookieOpts(request))
|
reply.setCookie('hnf_session', token, cookieOpts(request))
|
||||||
return full
|
return full
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue