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);
|
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
|
// Start backup service
|
||||||
await startBackupService();
|
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