From dfa6718cb54cda7354b2a3cf6df65e4ed83be199 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 31 Aug 2026 12:58:02 +0000 Subject: [PATCH] Fix Staff role cashup permission resets: seed once, not on empty-caps guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several 'grant Staff these default caps' seeds in initDb() re-ran on every auth container restart, guarded by 'role currently has no caps for this app' as a proxy for 'first deploy'. If an admin revoked all of Staff's cashup capabilities in the portal, the next restart saw zero cashup caps and silently re-granted all 7 non-settings caps back — looking like permissions randomly resetting to full access. Add a seed_flags table + seedOnce() helper so these default-cap grants (cashup x2, hk-planner, kds, tasks) run exactly once ever, tracked by a durable flag instead of re-derived from current state. Deliberate permission changes now stick across restarts/redeploys. Co-Authored-By: Claude Sonnet 5 --- src/db.js | 77 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/src/db.js b/src/db.js index 4dac08c..00a679b 100644 --- a/src/db.js +++ b/src/db.js @@ -104,8 +104,30 @@ export async function initDb() { granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, capability_id) ); + + -- Tracks one-time default-capability grants (e.g. "give Staff these caps on + -- first deploy") so they run exactly once ever. Without this, a seed guarded + -- by "role has no caps for this app yet" re-fires on every container restart + -- if an admin later revokes those caps back down to zero, silently undoing + -- the revocation. See seedOnce(). + CREATE TABLE IF NOT EXISTS seed_flags ( + key TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); `) + // Run `fn` only if `key` has never been applied before, then record it. + // Use this for any "grant default caps to a role" seed instead of checking + // current role_capabilities state — that state changes legitimately when an + // admin edits permissions in the portal, and re-deriving "first deploy" from + // it causes deliberate revocations to get silently reinstated on restart. + const seedOnce = async (key, fn) => { + const { rows } = await pool.query(`SELECT 1 FROM seed_flags WHERE key = $1`, [key]) + if (rows.length > 0) return + await fn() + await pool.query(`INSERT INTO seed_flags (key) VALUES ($1) ON CONFLICT DO NOTHING`, [key]) + } + // Migrations for existing installs await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS category VARCHAR(100)`) await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_host TEXT`) @@ -176,38 +198,32 @@ export async function initDb() { sort_order = EXCLUDED.sort_order `) - // Non-breaking initial seed: grant the default Staff role every cashup capability - // except 'settings' only when Staff has no cashup caps yet (first deploy). - await pool.query(` + // One-time initial seed: grant the default Staff role every cashup capability + // except 'settings'. Guarded by seed_flags, not current permission state — see + // seedOnce() above. (This used to re-fire and silently re-grant everything + // whenever an admin revoked all of Staff's cashup caps and the auth container + // restarted; that was the "cashup permissions keep resetting" bug, 2026-08-31.) + await seedOnce('cashup_staff_default_caps_v1', () => 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', 'history', 'reports', 'cash_summary', 'floats', 'safe_count') - 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 - `) + `)) - // Additive migration: grant Staff the three new split caps individually if - // not already present (handles existing installs that already had the old caps). - await pool.query(` + // One-time migration: grant Staff the three new split caps individually + // (handles existing installs that already had the old caps). + await seedOnce('cashup_staff_split_caps_v1', () => 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') WHERE r.slug = 'staff' AND ac.slug IN ('history', 'cash_summary', 'safe_count') - AND NOT EXISTS ( - SELECT 1 FROM role_capabilities rc WHERE rc.role_id = r.id AND rc.capability_id = ac.id - ) ON CONFLICT DO NOTHING - `) + `)) // Seed hk-planner capabilities await pool.query(` @@ -225,22 +241,16 @@ export async function initDb() { sort_order = EXCLUDED.sort_order `) - // Grant Staff role the planner cap by default (settings is admin-only) - await pool.query(` + // One-time seed: grant Staff role the planner cap by default (settings is admin-only) + await seedOnce('hk-planner_staff_default_caps_v1', () => 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 = 'hk-planner') - JOIN apps a ON a.id = ac.app_id WHERE r.slug = 'staff' AND ac.slug IN ('planner') - 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 forecasting capabilities await pool.query(` @@ -338,19 +348,16 @@ export async function initDb() { sort_order = EXCLUDED.sort_order `) - // Grant Staff role KDS view + manage (needed during service; settings stays admin-only) - await pool.query(` + // One-time seed: grant Staff role KDS view + manage (needed during service; settings stays admin-only) + await seedOnce('kds_staff_default_caps_v1', () => 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 = 'kds') WHERE r.slug = 'staff' AND ac.slug IN ('view', 'manage') - AND NOT EXISTS ( - SELECT 1 FROM role_capabilities rc WHERE rc.role_id = r.id AND rc.capability_id = ac.id - ) ON CONFLICT DO NOTHING - `) + `)) // Seed wages app + capabilities (management/finance only — no default Staff grants) await pool.query(` @@ -523,7 +530,9 @@ export async function initDb() { sort_order = EXCLUDED.sort_order `) - await pool.query(` + // One-time seed: grant Staff role the 'use' cap (unguarded before this used to + // re-insert every restart, which would have silently undone a revocation too). + await seedOnce('tasks_staff_default_caps_v1', () => pool.query(` INSERT INTO role_capabilities (role_id, capability_id) SELECT r.id, ac.id FROM roles r @@ -531,7 +540,7 @@ export async function initDb() { JOIN apps a ON a.id = ac.app_id AND a.slug = 'tasks' WHERE r.slug = 'staff' ON CONFLICT DO NOTHING - `) + `)) // Seed weather app + capability. LXC 131 — reception forecast display. base_path // is '/weather/admin', not '/weather': the portal card takes staff to the authed