diff --git a/backend/__tests__/integration/picpeakRestorePg.test.js b/backend/__tests__/integration/picpeakRestorePg.test.js new file mode 100644 index 00000000..09e9f468 --- /dev/null +++ b/backend/__tests__/integration/picpeakRestorePg.test.js @@ -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 }); + }); +}); diff --git a/backend/__tests__/services/picpeakRolePreserve.test.js b/backend/__tests__/services/picpeakRolePreserve.test.js new file mode 100644 index 00000000..81be6f07 --- /dev/null +++ b/backend/__tests__/services/picpeakRolePreserve.test.js @@ -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(); +}); diff --git a/backend/__tests__/utils/sessionCutoff.test.js b/backend/__tests__/utils/sessionCutoff.test.js new file mode 100644 index 00000000..2b0a5071 --- /dev/null +++ b/backend/__tests__/utils/sessionCutoff.test.js @@ -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); +}); diff --git a/backend/package.json b/backend/package.json index f9734117..9bb5a0ee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/__tests__/customerAuth.middleware.test.js b/backend/src/__tests__/customerAuth.middleware.test.js index e39b9dde..649a2cc4 100644 --- a/backend/src/__tests__/customerAuth.middleware.test.js +++ b/backend/src/__tests__/customerAuth.middleware.test.js @@ -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(), })); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index ccb49903..ed6faba5 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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') diff --git a/backend/src/middleware/customerAuth.js b/backend/src/middleware/customerAuth.js index e1b9c545..1ce58c2e 100644 --- a/backend/src/middleware/customerAuth.js +++ b/backend/src/middleware/customerAuth.js @@ -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, diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 409d14a8..46673bc3 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -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) { diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js index 27ac33a0..8cd054a4 100644 --- a/backend/src/services/picpeakImportService.js +++ b/backend/src/services/picpeakImportService.js @@ -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, }; diff --git a/backend/src/utils/sessionCutoff.js b/backend/src/utils/sessionCutoff.js new file mode 100644 index 00000000..d325d179 --- /dev/null +++ b/backend/src/utils/sessionCutoff.js @@ -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, +};