Add AES-256-GCM encryption for sensitive personal data fields

- New backend/src/lib/crypto.ts: encrypt/decrypt (AES-256-GCM) + passportToken (HMAC-SHA256)
- Migration 012: birth_date/passport_issue_date → TEXT, add passport_search_token indexes
- guests.ts: encrypt passport_series/number/birth_date on write, decrypt on read,
  passport search via HMAC token (?passport= param) instead of plaintext LIKE
- booking-guests.ts: encrypt all sensitive fields, search token for passport lookup
- api.ts: guests.list() supports passport= param for exact passport lookup
- BookingDetailPanel: use passport param for passport-based autocomplete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 17:19:27 +03:00
parent aa4a1fdb39
commit 8282155237
6 changed files with 236 additions and 71 deletions

View File

@@ -1,7 +1,8 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { encryptField, decryptField, passportToken } from '../lib/crypto'
type Params = { Params: { slug: string; bookingId: string } }
type Params = { Params: { slug: string; bookingId: string } }
type ParamsWithId = { Params: { slug: string; bookingId: string; id: string } }
type GuestBody = Partial<{
@@ -12,6 +13,18 @@ type GuestBody = Partial<{
nationality: string
}>
// Расшифровывает чувствительные поля строки booking_guest перед отправкой клиенту
function decryptBg(row: Record<string, unknown>) {
return {
...row,
passport_series: decryptField(row.passport_series as string | null),
passport_number: decryptField(row.passport_number as string | null),
passport_issued_by: decryptField(row.passport_issued_by as string | null),
passport_issue_date:decryptField(row.passport_issue_date as string | null),
birth_date: decryptField(row.birth_date as string | null),
}
}
const bookingGuests: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
@@ -40,7 +53,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
ORDER BY bg.is_main DESC, bg.is_child ASC, bg.created_at ASC`,
[bookingId, hotelId],
)
return rows
return rows.map(decryptBg)
},
)
@@ -56,7 +69,6 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
// Verify booking belongs to hotel
const { rows: [bk] } = await db.query(
'SELECT id FROM bookings WHERE id = $1 AND hotel_id = $2',
[bookingId, hotelId],
@@ -66,14 +78,15 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
const b = request.body
let guestId: string | null = null
// Auto-link or create guest profile if passport provided
// Ищем/создаём гостя по паспортному токену
if (b.passport_series && b.passport_number) {
const series = b.passport_series.trim()
const number = b.passport_number.trim()
const token = passportToken(series + number)
const { rows: existing } = await db.query(
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_series = $2 AND passport_number = $3`,
[hotelId, series, number],
`SELECT id FROM guests WHERE hotel_id = $1 AND passport_search_token = $2`,
[hotelId, token],
)
if (existing.length > 0) {
@@ -86,25 +99,30 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
} else {
const { rows: [ng] } = await db.query(
`INSERT INTO guests
(hotel_id, first_name, last_name, passport_series, passport_number, birth_date, nationality, notes, tags, rating)
VALUES ($1, $2, $3, $4, $5, $6, $7, '', '{}', 3)
(hotel_id, first_name, last_name, passport_series, passport_number,
passport_search_token, birth_date, nationality, notes, tags, rating)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '', '{}', 3)
RETURNING id`,
[hotelId, b.first_name, b.last_name, series, number, b.birth_date ?? null, b.nationality ?? null],
[
hotelId, b.first_name, b.last_name,
encryptField(series), encryptField(number),
token,
encryptField(b.birth_date ?? null),
b.nationality ?? null,
],
)
guestId = ng.id
}
} else if (!b.is_child) {
// Create a minimal guest profile for adults even without passport
const { rows: [ng] } = await db.query(
`INSERT INTO guests (hotel_id, first_name, last_name, birth_date, notes, tags, rating)
VALUES ($1, $2, $3, $4, '', '{}', 3)
RETURNING id`,
[hotelId, b.first_name, b.last_name, b.birth_date ?? null],
[hotelId, b.first_name, b.last_name, encryptField(b.birth_date ?? null)],
)
guestId = ng.id
}
// If marking as main, clear previous main flag
if (b.is_main) {
await db.query(
`UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2`,
@@ -112,24 +130,31 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
)
}
const bgToken = (b.passport_series && b.passport_number)
? passportToken(b.passport_series.trim() + b.passport_number.trim())
: null
const { rows: [bg] } = await db.query(
`INSERT INTO booking_guests
(booking_id, hotel_id, guest_id, first_name, last_name, middle_name,
birth_date, is_child, is_main, passport_series, passport_number,
passport_issued_by, passport_issue_date, nationality)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
passport_issued_by, passport_issue_date, nationality, passport_search_token)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING *`,
[
bookingId, hotelId, guestId,
b.first_name, b.last_name, b.middle_name ?? null,
b.birth_date ?? null, b.is_child ?? false, b.is_main ?? false,
b.passport_series ?? null, b.passport_number ?? null,
b.passport_issued_by ?? null, b.passport_issue_date ?? null,
encryptField(b.birth_date ?? null),
b.is_child ?? false, b.is_main ?? false,
encryptField(b.passport_series ?? null),
encryptField(b.passport_number ?? null),
encryptField(b.passport_issued_by ?? null),
encryptField(b.passport_issue_date ?? null),
b.nationality ?? null,
bgToken,
],
)
// If main guest, link to booking
if (b.is_main && guestId) {
await db.query(
`UPDATE bookings SET guest_id = $1, updated_at = NOW() WHERE id = $2 AND hotel_id = $3`,
@@ -137,7 +162,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
)
}
return reply.code(201).send(bg)
return reply.code(201).send(decryptBg(bg))
},
)
@@ -155,7 +180,6 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
const b = request.body
// If setting is_main, clear other rows first
if (b.is_main) {
await db.query(
`UPDATE booking_guests SET is_main = false WHERE booking_id = $1 AND hotel_id = $2 AND id != $3`,
@@ -170,17 +194,29 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
const add = (col: string, val: unknown) => {
if (val !== undefined) { sets.push(`${col} = $${idx++}`); vals.push(val) }
}
add('first_name', b.first_name)
add('last_name', b.last_name)
add('first_name', b.first_name)
add('last_name', b.last_name)
add('middle_name', b.middle_name)
add('birth_date', b.birth_date || null)
add('is_child', b.is_child)
add('is_main', b.is_main)
add('passport_series', b.passport_series)
add('passport_number', b.passport_number)
add('passport_issued_by', b.passport_issued_by)
add('passport_issue_date', b.passport_issue_date || null)
add('is_child', b.is_child)
add('is_main', b.is_main)
add('nationality', b.nationality)
// Шифруем чувствительные поля
if (b.birth_date !== undefined) add('birth_date', encryptField(b.birth_date || null))
if (b.passport_series !== undefined) add('passport_series', encryptField(b.passport_series))
if (b.passport_number !== undefined) add('passport_number', encryptField(b.passport_number))
if (b.passport_issued_by !== undefined) add('passport_issued_by', encryptField(b.passport_issued_by))
if (b.passport_issue_date !== undefined)add('passport_issue_date', encryptField(b.passport_issue_date || null))
// Обновляем токен если поменялся паспорт
if (b.passport_series !== undefined || b.passport_number !== undefined) {
const { rows: [cur] } = await db.query(
'SELECT passport_series, passport_number FROM booking_guests WHERE id = $1',
[id],
)
const series = b.passport_series ?? decryptField(cur?.passport_series) ?? ''
const number = b.passport_number ?? decryptField(cur?.passport_number) ?? ''
if (series && number) add('passport_search_token', passportToken(series.trim() + number.trim()))
}
if (sets.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
sets.push('updated_at = NOW()')
@@ -193,7 +229,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
)
if (!bg) return reply.code(404).send({ error: 'Not found' })
// Sync subset of fields to guest profile
// Синхронизируем часть полей в профиль гостя
if (bg.guest_id) {
const gSets: string[] = []
const gVals: unknown[] = [bg.guest_id, hotelId]
@@ -202,11 +238,17 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
if (val !== undefined) { gSets.push(`${col} = $${gi++}`); gVals.push(val) }
}
addG('first_name', b.first_name)
addG('last_name', b.last_name)
addG('passport_series', b.passport_series)
addG('passport_number', b.passport_number)
addG('birth_date', b.birth_date)
addG('last_name', b.last_name)
if (b.passport_series !== undefined) addG('passport_series', encryptField(b.passport_series))
if (b.passport_number !== undefined) addG('passport_number', encryptField(b.passport_number))
if (b.birth_date !== undefined) addG('birth_date', encryptField(b.birth_date || null))
addG('nationality', b.nationality)
// Обновляем токен в профиле гостя
if (b.passport_series !== undefined || b.passport_number !== undefined) {
const series = b.passport_series ?? decryptField(bg.passport_series as string) ?? ''
const number = b.passport_number ?? decryptField(bg.passport_number as string) ?? ''
if (series && number) addG('passport_search_token', passportToken(series.trim() + number.trim()))
}
if (gSets.length > 0) {
gSets.push('updated_at = NOW()')
await db.query(
@@ -216,7 +258,7 @@ const bookingGuests: FastifyPluginAsync = async (fastify) => {
}
}
return bg
return decryptBg(bg)
},
)