340d91bdd5
Implements the three restore-hardening items deferred from the #811 Codex review (all validated against a real Postgres, see __tests__/integration/ picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport). 1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/ customer/event ids, so ANY pre-restore JWT can rebind to a different restored principal. Revoking just the importing token wasn't enough. importFromPicpeak now stamps a unix-second cutoff in app_settings after the restore commits, and adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token whose iat predates it (cached 30s → one in-memory compare on the hot path). The operator's forced re-login mints a token past the cutoff, so it passes. 2. Role preservation across an RBAC replace (captureOperatorRole / preserveOperatorRole). The operator's role + granted permission NAMES are captured before the wipe; after roles/role_permissions are replaced the role is resolved by NAME against the restored data, and re-created with its grants if the backup omits it — so a crafted or cross-instance backup can't silently downgrade or lock out the operator. reinjectCurrentAdmin now returns the operator's id so the row can be re-pointed at the resolved role. 3. Postgres identity-sequence resync (resyncSequences). batchInsert writes explicit ids without advancing the sequences, so the next natural insert into any restored table collided on the PK. Runs AFTER commit (setval isn't transactional) and guards every table with a column-existence check — pg_get_serial_sequence RAISES on id-less tables like role_permissions. No-op on SQLite. Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence resync, the id-less-table guard, explicit-id reinject, role re-creation, and a full cross-instance replaceAllTables run asserting operator preservation, role re-establishment, FK integrity, and collision-free post-restore inserts. Stacks on #811 (shares the reinject hardening); merge after it.
101 lines
3.5 KiB
JavaScript
101 lines
3.5 KiB
JavaScript
/**
|
|
* Global session cutoff.
|
|
*
|
|
* A .picpeak restore rewrites admin_users / customer_accounts / events and can
|
|
* reassign their primary keys, so any JWT issued BEFORE the restore may now
|
|
* resolve to a different restored principal (auth middleware binds a token to
|
|
* `decoded.id`; IP is only logged and the backup controls each row's
|
|
* `password_changed_at`). Revoking the single importing token is not enough —
|
|
* every pre-restore admin, customer, and gallery session must stop being
|
|
* honoured.
|
|
*
|
|
* We record a single unix-second cutoff in app_settings and reject any token
|
|
* whose `iat` predates it, across all three JWT auth paths. The operator's
|
|
* forced re-login mints a token with `iat >= cutoff`, so it passes; everything
|
|
* issued earlier is refused. The value is cached briefly so the common auth
|
|
* path stays a single in-memory comparison.
|
|
*/
|
|
const { db } = require('../database/db');
|
|
const logger = require('./logger');
|
|
|
|
const CUTOFF_KEY = 'security_sessions_valid_after';
|
|
const CACHE_MS = 30 * 1000; // restores are rare; a short TTL keeps auth cheap
|
|
|
|
let cache = null; // { value: number, expiry: number }
|
|
|
|
async function readCutoffFromDb() {
|
|
const row = await db('app_settings')
|
|
.where('setting_key', CUTOFF_KEY)
|
|
.first()
|
|
.timeout(5000);
|
|
if (!row || row.setting_value == null) return 0;
|
|
let value = row.setting_value;
|
|
// pg `json` returns a parsed number; sqlite returns the stored string.
|
|
if (typeof value === 'string') {
|
|
try { value = JSON.parse(value); } catch (_) { /* fall through to parseInt */ }
|
|
}
|
|
const seconds = parseInt(value, 10);
|
|
return Number.isFinite(seconds) ? seconds : 0;
|
|
}
|
|
|
|
/**
|
|
* Cutoff as unix seconds (0 = no cutoff set). Cached for CACHE_MS. On a
|
|
* transient DB error, returns the last known value (or 0) rather than blocking
|
|
* auth — the cutoff is defence-in-depth layered on top of per-token revocation.
|
|
*/
|
|
async function getSessionsValidAfter() {
|
|
const now = Date.now();
|
|
if (cache && now < cache.expiry) return cache.value;
|
|
try {
|
|
const value = await readCutoffFromDb();
|
|
cache = { value, expiry: now + CACHE_MS };
|
|
return value;
|
|
} catch (err) {
|
|
logger.warn('[sessionCutoff] failed to read cutoff:', err.message);
|
|
return cache ? cache.value : 0;
|
|
}
|
|
}
|
|
|
|
/** Persist a new cutoff (unix seconds) and refresh the in-process cache. */
|
|
async function setSessionsValidAfter(unixSeconds) {
|
|
await db('app_settings')
|
|
.insert({
|
|
setting_key: CUTOFF_KEY,
|
|
setting_value: JSON.stringify(unixSeconds),
|
|
setting_type: 'number',
|
|
updated_at: new Date(),
|
|
})
|
|
.onConflict('setting_key')
|
|
.merge({ setting_value: JSON.stringify(unixSeconds), setting_type: 'number', updated_at: new Date() });
|
|
cache = { value: unixSeconds, expiry: Date.now() + CACHE_MS };
|
|
}
|
|
|
|
/**
|
|
* True when this token was issued before the global cutoff. Fail-open on any
|
|
* error: the cutoff is defence-in-depth on top of per-token revocation and the
|
|
* post-restore cookie clear, and must never turn a transient read failure into
|
|
* an auth outage.
|
|
*/
|
|
async function isTokenBeforeCutoff(decoded) {
|
|
try {
|
|
if (!decoded || !decoded.iat) return false;
|
|
const cutoff = await getSessionsValidAfter();
|
|
if (!cutoff) return false;
|
|
return decoded.iat < cutoff;
|
|
} catch (err) {
|
|
logger.warn('[sessionCutoff] check failed, allowing token:', err.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Test-only: drop the in-process cache. */
|
|
function _resetCache() { cache = null; }
|
|
|
|
module.exports = {
|
|
CUTOFF_KEY,
|
|
getSessionsValidAfter,
|
|
setSessionsValidAfter,
|
|
isTokenBeforeCutoff,
|
|
_resetCache,
|
|
};
|