Fix Staff role cashup permission resets: seed once, not on empty-caps guess
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 <noreply@anthropic.com>
This commit is contained in:
parent
bf5aa5bf24
commit
dfa6718cb5
1 changed files with 43 additions and 34 deletions
77
src/db.js
77
src/db.js
|
|
@ -104,8 +104,30 @@ export async function initDb() {
|
||||||
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
PRIMARY KEY (user_id, capability_id)
|
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
|
// 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 category VARCHAR(100)`)
|
||||||
await pool.query(`ALTER TABLE apps ADD COLUMN IF NOT EXISTS internal_host TEXT`)
|
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
|
sort_order = EXCLUDED.sort_order
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Non-breaking initial seed: grant the default Staff role every cashup capability
|
// One-time initial seed: grant the default Staff role every cashup capability
|
||||||
// except 'settings' only when Staff has no cashup caps yet (first deploy).
|
// except 'settings'. Guarded by seed_flags, not current permission state — see
|
||||||
await pool.query(`
|
// 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)
|
INSERT INTO role_capabilities (role_id, capability_id)
|
||||||
SELECT r.id, ac.id
|
SELECT r.id, ac.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'cashup')
|
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'
|
WHERE r.slug = 'staff'
|
||||||
AND ac.slug IN ('count', 'finalise', 'history', 'reports', 'cash_summary', 'floats', 'safe_count')
|
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
|
ON CONFLICT DO NOTHING
|
||||||
`)
|
`))
|
||||||
|
|
||||||
// Additive migration: grant Staff the three new split caps individually if
|
// One-time migration: grant Staff the three new split caps individually
|
||||||
// not already present (handles existing installs that already had the old caps).
|
// (handles existing installs that already had the old caps).
|
||||||
await pool.query(`
|
await seedOnce('cashup_staff_split_caps_v1', () => pool.query(`
|
||||||
INSERT INTO role_capabilities (role_id, capability_id)
|
INSERT INTO role_capabilities (role_id, capability_id)
|
||||||
SELECT r.id, ac.id
|
SELECT r.id, ac.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'cashup')
|
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'cashup')
|
||||||
WHERE r.slug = 'staff'
|
WHERE r.slug = 'staff'
|
||||||
AND ac.slug IN ('history', 'cash_summary', 'safe_count')
|
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
|
ON CONFLICT DO NOTHING
|
||||||
`)
|
`))
|
||||||
|
|
||||||
// Seed hk-planner capabilities
|
// Seed hk-planner capabilities
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
|
|
@ -225,22 +241,16 @@ export async function initDb() {
|
||||||
sort_order = EXCLUDED.sort_order
|
sort_order = EXCLUDED.sort_order
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Grant Staff role the planner cap by default (settings is admin-only)
|
// One-time seed: grant Staff role the planner cap by default (settings is admin-only)
|
||||||
await pool.query(`
|
await seedOnce('hk-planner_staff_default_caps_v1', () => pool.query(`
|
||||||
INSERT INTO role_capabilities (role_id, capability_id)
|
INSERT INTO role_capabilities (role_id, capability_id)
|
||||||
SELECT r.id, ac.id
|
SELECT r.id, ac.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'hk-planner')
|
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'
|
WHERE r.slug = 'staff'
|
||||||
AND ac.slug IN ('planner')
|
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
|
ON CONFLICT DO NOTHING
|
||||||
`)
|
`))
|
||||||
|
|
||||||
// Seed forecasting capabilities
|
// Seed forecasting capabilities
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
|
|
@ -338,19 +348,16 @@ export async function initDb() {
|
||||||
sort_order = EXCLUDED.sort_order
|
sort_order = EXCLUDED.sort_order
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Grant Staff role KDS view + manage (needed during service; settings stays admin-only)
|
// One-time seed: grant Staff role KDS view + manage (needed during service; settings stays admin-only)
|
||||||
await pool.query(`
|
await seedOnce('kds_staff_default_caps_v1', () => pool.query(`
|
||||||
INSERT INTO role_capabilities (role_id, capability_id)
|
INSERT INTO role_capabilities (role_id, capability_id)
|
||||||
SELECT r.id, ac.id
|
SELECT r.id, ac.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'kds')
|
JOIN app_capabilities ac ON ac.app_id = (SELECT id FROM apps WHERE slug = 'kds')
|
||||||
WHERE r.slug = 'staff'
|
WHERE r.slug = 'staff'
|
||||||
AND ac.slug IN ('view', 'manage')
|
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
|
ON CONFLICT DO NOTHING
|
||||||
`)
|
`))
|
||||||
|
|
||||||
// Seed wages app + capabilities (management/finance only — no default Staff grants)
|
// Seed wages app + capabilities (management/finance only — no default Staff grants)
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
|
|
@ -523,7 +530,9 @@ export async function initDb() {
|
||||||
sort_order = EXCLUDED.sort_order
|
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)
|
INSERT INTO role_capabilities (role_id, capability_id)
|
||||||
SELECT r.id, ac.id
|
SELECT r.id, ac.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
|
|
@ -531,7 +540,7 @@ export async function initDb() {
|
||||||
JOIN apps a ON a.id = ac.app_id AND a.slug = 'tasks'
|
JOIN apps a ON a.id = ac.app_id AND a.slug = 'tasks'
|
||||||
WHERE r.slug = 'staff'
|
WHERE r.slug = 'staff'
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`)
|
`))
|
||||||
|
|
||||||
// Seed weather app + capability. LXC 131 — reception forecast display. base_path
|
// Seed weather app + capability. LXC 131 — reception forecast display. base_path
|
||||||
// is '/weather/admin', not '/weather': the portal card takes staff to the authed
|
// is '/weather/admin', not '/weather': the portal card takes staff to the authed
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue