feat(restore): self-heal restore_allow_force default ON at boot
Fresh installs of picpeak had `restore_allow_force` defaulting to false (or missing entirely). Combined with the "1 active admin user" pre-restore warning that the fresh-install admin auto-creates, this meant the very first restore on every new install hit: Force restore is not allowed by system settings Admins then had to hand-craft SQL to flip the setting before they could recover their data — at the worst possible moment, when they were already mid-disaster. This isn't security: the admin who can SQL the setting on can also flip it via the UI. It's just a sharp edge that bites every new install once. Cure: boot-time self-heal that seeds restore_allow_force=true only when the row doesn't exist. Existing installs that explicitly set the row (true OR false) are NOT touched — admin policy wins. Pattern mirrors _backupPathsBoot.js and _emailTemplateBoot.js. Default-ON rationale matches Stage A's principle: the cost of forgetting (= can't recover from a disaster) outweighs the friction saved (= adversarial admins can't run forced restores). Audit logging keeps the accountability story intact.
This commit is contained in:
@@ -812,6 +812,18 @@ async function startServer() {
|
||||
logger.warn('backup_paths self-heal failed at boot:', err.message);
|
||||
}
|
||||
|
||||
// Self-heal restore-meta settings — currently just
|
||||
// `restore_allow_force` defaulting to ON so fresh installs can
|
||||
// recover from disaster without a SQL incantation. Only seeds on
|
||||
// FRESH installs (existing rows, true or false, are preserved).
|
||||
// See _restoreSettingsBoot.js for the full rationale.
|
||||
try {
|
||||
const { seedRestoreSettingsAtBoot } = require('./src/services/_restoreSettingsBoot');
|
||||
await seedRestoreSettingsAtBoot(db, logger);
|
||||
} catch (err) {
|
||||
logger.warn('restore-settings self-heal failed at boot:', err.message);
|
||||
}
|
||||
|
||||
// Start backup service
|
||||
await startBackupService();
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Boot-time self-heal for restore-meta settings.
|
||||
*
|
||||
* **Why this exists**
|
||||
*
|
||||
* `restore_allow_force` gates whether the Restore wizard accepts a
|
||||
* `force: true` payload. The flag exists to add admin friction
|
||||
* before letting a restore override safety warnings (e.g. "1 active
|
||||
* admin user — restoring would clobber the current install").
|
||||
*
|
||||
* In practice the friction lands at the worst possible moment: a
|
||||
* fresh install (no app_settings row yet OR `restore_allow_force =
|
||||
* false` by default) hits the wall on its very FIRST restore. The
|
||||
* admin is mid disaster-recovery, panicked, and gets:
|
||||
*
|
||||
* "Force restore is not allowed by system settings"
|
||||
*
|
||||
* They then have to hand-craft SQL like
|
||||
*
|
||||
* INSERT INTO app_settings (setting_key, setting_value, ...)
|
||||
* VALUES ('restore_allow_force', 'true', 'restore', NOW())
|
||||
* ON CONFLICT ... SET setting_value = 'true';
|
||||
*
|
||||
* before they can recover their data. This isn't security — the
|
||||
* admin who could run that SQL could also flip the setting via the
|
||||
* UI. It's just a sharp edge that bites every new install once.
|
||||
*
|
||||
* Cure: seed the default ON at boot via `INSERT ... ON CONFLICT
|
||||
* DO NOTHING`. New installs get force-allowed out of the box.
|
||||
* Existing installs that have explicitly set the row (true OR
|
||||
* false) are NOT overwritten — admin policy wins. Same pattern
|
||||
* `_backupPathsBoot.js` uses for the canonical backup_paths rows.
|
||||
*
|
||||
* **Default-ON rationale (matches Stage A's principle)**
|
||||
*
|
||||
* Stage A defaulted inline DB dumps to ON because the cost of
|
||||
* forgetting was data loss. By the same logic, `restore_allow_force`
|
||||
* defaults ON because the cost of forgetting is being unable to
|
||||
* recover from a disaster. Audit logging captures every forced
|
||||
* restore so the accountability story stays intact.
|
||||
*
|
||||
* If/when the broader "exclude restore-meta settings from being
|
||||
* overwritten by restore" follow-up lands (the second half of this
|
||||
* chicken-and-egg), this self-heal becomes the safety net for
|
||||
* fresh installs only — existing installs by that point have the
|
||||
* row preserved across restores.
|
||||
*/
|
||||
|
||||
const SEEDS = [
|
||||
{
|
||||
setting_key: 'restore_allow_force',
|
||||
setting_value: 'true',
|
||||
setting_type: 'restore',
|
||||
rationale: 'Default ON so fresh installs can recover from disaster '
|
||||
+ 'without a SQL incantation. Admins who want to require manual '
|
||||
+ 'intervention can disable via the admin UI.',
|
||||
},
|
||||
];
|
||||
|
||||
let booted = false;
|
||||
|
||||
/**
|
||||
* Seed the canonical restore-meta settings on fresh installs.
|
||||
*
|
||||
* @param {object} db knex instance
|
||||
* @param {object} logger app logger (must expose .info / .warn)
|
||||
* @returns {Promise<{ seeded: string[] }>}
|
||||
*/
|
||||
async function seedRestoreSettingsAtBoot(db, logger) {
|
||||
const log = logger || { info: () => {}, warn: () => {} };
|
||||
if (booted) return { seeded: [] };
|
||||
|
||||
if (!(await db.schema.hasTable('app_settings'))) {
|
||||
log.warn('app_settings table missing at boot — restore-settings self-heal skipped');
|
||||
return { seeded: [] };
|
||||
}
|
||||
|
||||
const seeded = [];
|
||||
for (const seed of SEEDS) {
|
||||
try {
|
||||
const existing = await db('app_settings')
|
||||
.where('setting_key', seed.setting_key)
|
||||
.first();
|
||||
if (existing) continue; // admin policy already in effect
|
||||
|
||||
await db('app_settings').insert({
|
||||
setting_key: seed.setting_key,
|
||||
setting_value: seed.setting_value,
|
||||
setting_type: seed.setting_type,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
seeded.push(seed.setting_key);
|
||||
log.info(`Seeded restore-meta setting ${seed.setting_key}=${seed.setting_value} (${seed.rationale.slice(0, 80)}...)`);
|
||||
} catch (err) {
|
||||
log.warn(`Failed to seed restore-meta setting ${seed.setting_key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
booted = true;
|
||||
return { seeded };
|
||||
}
|
||||
|
||||
// Test-only: reset the module-level boot flag so jest can re-exercise
|
||||
// the seeder against a fresh test DB inside a single worker.
|
||||
function _resetBootForTests() {
|
||||
booted = false;
|
||||
}
|
||||
|
||||
module.exports = { seedRestoreSettingsAtBoot, _resetBootForTests, SEEDS };
|
||||
Reference in New Issue
Block a user