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,
|
||||
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
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue