feat(security): harden .picpeak restore robustness — sessions, roles, sequences
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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -186,18 +186,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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user