Merge pull request #813 from PicPeak/feat/harden-picpeak-restore-robustness

feat(security): harden .picpeak restore robustness — sessions, roles, sequences
This commit is contained in:
Paul Nothaft
2026-07-16 13:39:17 +02:00
committed by GitHub
10 changed files with 595 additions and 17 deletions
@@ -0,0 +1,190 @@
/**
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
* e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
* npx jest __tests__/integration/picpeakRestorePg.test.js
*
* Validates the Postgres-specific paths that SQLite can't exercise: identity
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('picpeak restore on Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
await pgDb.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name', 50).notNullable().unique();
t.string('display_name', 100);
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await pgDb.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name', 100).notNullable().unique();
t.string('display_name', 150);
t.string('category', 50);
});
await pgDb.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
t.primary(['role_id', 'permission_id']);
});
await pgDb.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id').references('id').inTable('roles').onDelete('SET NULL');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.string('slug');
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.json('setting_value');
t.string('setting_type');
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('role_permissions').del();
await pgDb('events').del();
await pgDb('admin_users').del();
await pgDb('roles').del();
await pgDb('permissions').del();
});
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
// Simulate a restore: explicit-id inserts leave the sequence at 1.
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
// Natural inserts (no explicit id) now avoid the restored ids.
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
expect(Number(roleId.id || roleId)).toBe(6);
});
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op.id).toBe(10); // max(9)+1
expect(op.password_hash).toBe('OP');
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(role).toBeTruthy();
const op = await pgDb('admin_users').where({ id: 1 }).first();
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
});
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
// A backup from ANOTHER instance: omits the operator's email AND their
// super_admin role; uses explicit ids that leave sequences stale.
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
const dataDir = path.join(staging, 'data');
fs.mkdirSync(dataDir);
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
// replaceAllTables isn't exported, so drive its exact transaction sequence
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
// exported units against real Postgres.
const importSvc = svc;
await pgDb.transaction(async (trx) => {
await trx.raw('SET session_replication_role = \'replica\'');
for (const t of tables) await trx(t).del();
for (const t of tables) {
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
if (rows.length) await trx.batchInsert(t, rows, 100);
}
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
await trx.raw('SET session_replication_role = \'origin\'');
});
await importSvc.resyncSequences(tables);
// Operator preserved (inserted, since email absent from backup).
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
expect(op).toBeTruthy();
expect(op.password_hash).toBe('OP');
// super_admin role re-created and the operator bound to it.
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
expect(sa).toBeTruthy();
expect(op.role_id).toBe(sa.id);
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
// Restored event's created_by FK to the backup admin still valid.
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
expect(ev.created_by).toBe(9);
// Sequences resynced → natural inserts don't collide.
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
fs.rmSync(staging, { recursive: true, force: true });
});
});
@@ -0,0 +1,105 @@
/**
* Tests for preserveOperatorRole — re-establishing the operator's authorization
* after a restore replaces the roles / permissions / role_permissions tables.
* Real in-memory SQLite so the joins and inserts behave as in production.
*/
const knex = require('knex');
let db;
let svc;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('roles', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.integer('priority').defaultTo(0);
t.boolean('is_system').defaultTo(false);
});
await db.schema.createTable('permissions', (t) => {
t.increments('id');
t.string('name').notNullable().unique();
t.string('display_name');
t.string('category');
});
await db.schema.createTable('role_permissions', (t) => {
t.integer('role_id').notNullable();
t.integer('permission_id').notNullable();
t.primary(['role_id', 'permission_id']);
});
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('email');
t.integer('role_id');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }));
jest.doMock('../../src/database/db', () => ({ db }));
svc = require('../../src/services/picpeakImportService');
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
await db.destroy();
});
test('captureOperatorRole returns the role + its permission names', async () => {
await db('roles').insert({ id: 1, name: 'super_admin', display_name: 'Super Admin', priority: 100 });
await db('permissions').insert([
{ id: 1, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 2, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('role_permissions').insert([{ role_id: 1, permission_id: 1 }, { role_id: 1, permission_id: 2 }]);
const snap = await svc.captureOperatorRole(1);
expect(snap.role.name).toBe('super_admin');
expect(snap.permissions.sort()).toEqual(['events.create', 'users.manage']);
});
test('preserveOperatorRole binds to a restored role of the same NAME (ids remapped)', async () => {
const snapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
// Simulate post-restore RBAC where super_admin now has a DIFFERENT id.
await db('roles').insert({ id: 7, name: 'super_admin', display_name: 'Super Admin (restored)', priority: 100 });
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(7); // bound to restored super_admin by name
expect(await db('roles').count({ c: '*' }).first()).toEqual({ c: 1 }); // no duplicate role created
});
test('preserveOperatorRole re-creates the role + grants when the backup omits it', async () => {
const snapshot = {
role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true },
permissions: ['events.create', 'users.manage', 'gone.permission'],
};
// Post-restore RBAC WITHOUT super_admin; only some permissions exist.
await db('roles').insert({ id: 2, name: 'viewer', display_name: 'Viewer', priority: 10 });
await db('permissions').insert([
{ id: 5, name: 'events.create', display_name: 'Create', category: 'events' },
{ id: 6, name: 'users.manage', display_name: 'Manage', category: 'users' },
]);
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
const recreated = await db('roles').where({ name: 'super_admin' }).first();
expect(recreated).toBeTruthy(); // role re-created, not left missing
expect(recreated.id).toBe(3); // max(2)+1
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBe(recreated.id); // operator not locked out / downgraded
const grants = await db('role_permissions').where({ role_id: recreated.id }).pluck('permission_id');
expect(grants.sort()).toEqual([5, 6]); // existing perms re-granted; 'gone.permission' skipped
});
test('preserveOperatorRole no-ops when the operator had no role', async () => {
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, null));
const op = await db('admin_users').where({ id: 3 }).first();
expect(op.role_id).toBeNull();
});
@@ -0,0 +1,56 @@
/**
* Unit tests for the global session cutoff (utils/sessionCutoff.js). Uses a
* real in-memory SQLite `app_settings` table so the read/write/parse path is
* exercised exactly as in production.
*/
const knex = require('knex');
let db;
let cutoff;
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.text('setting_value');
t.string('setting_type');
t.timestamp('updated_at');
});
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
cutoff = require('../../src/utils/sessionCutoff');
cutoff._resetCache();
});
afterEach(async () => {
jest.dontMock('../../src/database/db');
await db.destroy();
});
test('no cutoff set → nothing is invalidated', async () => {
expect(await cutoff.getSessionsValidAfter()).toBe(0);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1000 })).toBe(false);
});
test('token issued before the cutoff is rejected, at/after is accepted', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({ iat: 1999 })).toBe(true); // pre-restore session
expect(await cutoff.isTokenBeforeCutoff({ iat: 2000 })).toBe(false); // same second → kept
expect(await cutoff.isTokenBeforeCutoff({ iat: 2001 })).toBe(false); // post-restore login
});
test('setSessionsValidAfter upserts a single row and refreshes the cache', async () => {
await cutoff.setSessionsValidAfter(1000);
await cutoff.setSessionsValidAfter(3000);
const rows = await db('app_settings').where('setting_key', 'security_sessions_valid_after');
expect(rows).toHaveLength(1);
cutoff._resetCache();
expect(await cutoff.getSessionsValidAfter()).toBe(3000);
});
test('a token without iat is never treated as before the cutoff', async () => {
await cutoff.setSessionsValidAfter(2000);
expect(await cutoff.isTokenBeforeCutoff({})).toBe(false);
expect(await cutoff.isTokenBeforeCutoff(null)).toBe(false);
});
+1
View File
@@ -11,6 +11,7 @@
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
"test:pg": "jest __tests__/integration/picpeakRestorePg",
"lint": "eslint src/"
},
"dependencies": {
@@ -33,6 +33,13 @@ jest.mock('../utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(),
}));
// The global session cutoff (added for .picpeak restore invalidation) queries
// app_settings; stub it to "no cutoff" so it doesn't consume this suite's
// one-shot db() mock. Its own behaviour is covered by utils/sessionCutoff.test.js.
jest.mock('../utils/sessionCutoff', () => ({
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false),
}));
jest.mock('../utils/tokenUtils', () => ({
getCustomerTokenFromRequest: jest.fn(),
}));
+19 -1
View File
@@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -38,6 +39,13 @@ async function adminAuth(req, res, next) {
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject any session issued before the global cutoff (set by a .picpeak
// restore, which can reassign admin ids). Forces every pre-restore admin
// session to re-authenticate against the restored data.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'admin') {
@@ -157,7 +165,12 @@ async function galleryAuth(req, res, next) {
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
@@ -221,6 +234,11 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
+6
View File
@@ -13,6 +13,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
@@ -61,6 +62,11 @@ async function customerAuth(req, res, next) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Reject sessions issued before the global restore cutoff.
if (await isTokenBeforeCutoff(decoded)) {
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
}
if (decoded.type !== 'customer') {
logger.warn('[customerAuth] wrong token type', {
url: req.originalUrl,
+9 -12
View File
@@ -197,18 +197,15 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
// The restore rewrote admin_users, so ids may have shifted. The operator's
// current JWT is bound only to the pre-restore admin id (adminAuth trusts
// `decoded.id` — IP is logged, not enforced, and the backup controls
// password_changed_at), which could now resolve to a DIFFERENT restored
// account and silently grant its permissions. Force a fresh login instead
// of trusting the old session: revoke the token and clear the cookie.
// Clearing the cookie is the guarantee — it drops the operator's browser
// session unconditionally. Revocation is the extra layer that also kills a
// Bearer-header copy of the JWT; revokeToken() swallows DB errors and
// returns false, so check the result and log loudly if the denylist write
// didn't land (the operator should still re-login, which the cookie clear
// forces).
// The restore rewrote admin_users, so ids may have shifted. importFromPicpeak
// already stamped a GLOBAL session cutoff (see setSessionsValidAfter), so
// every JWT issued before the restore — admin, customer, gallery — now fails
// auth. Here we additionally give the importing admin an immediate, clean
// logout: revoke this token and clear the cookie so their browser drops the
// session at once rather than on the next 401. Cookie clear is the
// unconditional guarantee; revokeToken() swallows DB errors and returns
// false, so check the result and log loudly if the denylist write didn't
// land (the operator still re-logs-in, which the cookie clear forces).
let tokenRevoked = false;
try {
if (req.token) {
+102 -4
View File
@@ -23,6 +23,7 @@ const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const { getStoragePath } = require('../config/storage');
const { hasColumnCached } = require('../utils/schemaCache');
const { setSessionsValidAfter } = require('../utils/sessionCutoff');
const logger = require('../utils/logger');
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
@@ -105,7 +106,7 @@ function parseNdjson(filePath) {
// preserved, its own FKs stay valid) to free the username;
// - only when no row has the operator's email do we insert a fresh row.
async function reinjectCurrentAdmin(trx, currentAdmin) {
if (!currentAdmin) return;
if (!currentAdmin) return null;
const emailMatch = await trx('admin_users')
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
@@ -135,6 +136,7 @@ async function reinjectCurrentAdmin(trx, currentAdmin) {
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
}
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
return emailMatch.id;
} else {
// The operator's email isn't in the backup, so nothing restored references
// their id — a fresh row can't dangle a reference TO the operator. Null the
@@ -149,6 +151,84 @@ async function reinjectCurrentAdmin(trx, currentAdmin) {
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
await trx('admin_users').insert(snapshot);
return snapshot.id;
}
}
// Capture the operator's role and its granted permission NAMES before the wipe,
// so preserveOperatorRole() can re-establish the operator's authorization after
// the RBAC tables are replaced. Permission NAMES (not ids) are captured because
// the restored permissions table reassigns ids. Returns null if the operator
// has no role.
async function captureOperatorRole(roleId) {
if (!roleId) return null;
const role = await db('roles').where({ id: roleId }).first();
if (!role) return null;
const permissions = await db('role_permissions')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.where('role_permissions.role_id', roleId)
.pluck('permissions.name');
return { role, permissions };
}
// Restore the operator's authorization after roles/role_permissions are
// replaced. A restore rewrites the RBAC tables, so the operator's pre-restore
// role_id may now name a different (or missing) role — a crafted backup could
// silently downgrade them, and reinjectCurrentAdmin deliberately does NOT copy
// role_id (it could dangle). Here we resolve the role by NAME against the
// restored data: if a role with the operator's role name exists we trust it
// (it's the backup the operator chose to restore); otherwise we re-create the
// role from the captured snapshot and re-grant the captured permissions that
// still exist, so the operator can never be locked out of their own instance.
async function preserveOperatorRole(trx, operatorId, snapshot) {
if (!operatorId || !snapshot || !snapshot.role) return;
const { role, permissions } = snapshot;
let target = await trx('roles').whereRaw('lower(name) = lower(?)', [role.name]).first();
if (!target) {
const roleRow = { ...role };
delete roleRow.id;
const maxRole = await trx('roles').max({ m: 'id' }).first();
const newRoleId = (Number(maxRole && maxRole.m) || 0) + 1; // sequence resynced post-commit
roleRow.id = newRoleId;
await trx('roles').insert(roleRow);
if (permissions && permissions.length) {
const perms = await trx('permissions').whereIn('name', permissions).select('id');
if (perms.length) {
await trx('role_permissions').insert(
perms.map((p) => ({ role_id: newRoleId, permission_id: p.id }))
);
}
}
target = { id: newRoleId };
}
await trx('admin_users').where({ id: operatorId }).update({ role_id: target.id });
}
// Fast-forward each restored table's Postgres identity sequence to its current
// max(id). batchInsert writes explicit ids without advancing the sequence, so
// the next natural insert into any restored table (a new event, an accepted
// invitation, etc.) would otherwise collide on the primary key. Runs AFTER the
// restore transaction commits (setval is non-transactional and would survive a
// rollback) and guards every table with a column-existence check —
// pg_get_serial_sequence RAISES on a table lacking an `id` column (e.g. the
// composite-key role_permissions), so an unguarded call would abort here.
// No-op on SQLite, whose AUTOINCREMENT tracks the high-water mark itself.
async function resyncSequences(tables) {
if (!isPostgres()) return;
for (const table of tables) {
try {
if (!(await db.schema.hasColumn(table, 'id'))) continue;
const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']);
const seq = res && res.rows && res.rows[0] && res.rows[0].seq;
if (!seq) continue; // `id` isn't a serial/identity column
await db.raw(
'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))',
[seq, table, table]
);
} catch (err) {
logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`);
}
}
}
@@ -188,7 +268,7 @@ function serialiseJsonColumns(rows, jsonCols) {
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin) {
async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
@@ -218,7 +298,10 @@ async function replaceAllTables(tables, dataDir, currentAdmin) {
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
}
await reinjectCurrentAdmin(trx, currentAdmin);
const operatorId = await reinjectCurrentAdmin(trx, currentAdmin);
if (operatorId && roleSnapshot) {
await preserveOperatorRole(trx, operatorId, roleSnapshot);
}
// Reset the pg session flag BEFORE the connection returns to the pool.
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
@@ -288,6 +371,9 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
// Capture the operator's role + granted permission names BEFORE the wipe so
// their authorization can be re-established after the RBAC tables are replaced.
const roleSnapshot = currentAdmin ? await captureOperatorRole(currentAdmin.role_id) : null;
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
try {
@@ -316,7 +402,16 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin);
await replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot);
// Post-commit fixups (must NOT run inside the restore transaction):
// - resync Postgres identity sequences left behind by the explicit-id
// batchInsert, so the next natural insert doesn't collide;
// - stamp a global session cutoff so every JWT issued before this restore
// (admin, customer, gallery) stops authenticating — ids may have shifted.
await resyncSequences(tables);
await setSessionsValidAfter(Math.floor(Date.now() / 1000));
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
@@ -334,4 +429,7 @@ module.exports = {
readManifestFromZip,
validateManifest,
reinjectCurrentAdmin,
captureOperatorRole,
preserveOperatorRole,
resyncSequences,
};
+100
View File
@@ -0,0 +1,100 @@
/**
* 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,
};