/** * Prisma query-extension that transparently encrypts PII fields on write and * decrypts them on read. Works via Prisma `$extends({ query: … })`. * * Design principles: * - Zero changes required in business logic / repositories * - Searchable fields also get a `{field}Hash` written on create/update * - Decryption is applied to all query results automatically * - Non-encrypted (plaintext) values pass through unchanged — safe for * incremental migration */ import { Prisma } from '@prisma/client'; import { FieldEncryptionService, type ModelEncryptionConfig, type ModelEncryptionFieldConfig, } from './field-encryption.service'; // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function encryptDataObject( data: Record, fields: ModelEncryptionFieldConfig[], service: FieldEncryptionService, ): void { const encryptedFields: string[] = []; for (const fieldConfig of fields) { const value = data[fieldConfig.field]; if (value === undefined || value === null) continue; // Skip if already encrypted (idempotent) if (service.isAlreadyEncrypted(value)) continue; // Compute deterministic hash for searchable fields BEFORE encryption if (fieldConfig.searchable && typeof value === 'string') { data[`${fieldConfig.field}Hash`] = service.computeHash(value); } data[fieldConfig.field] = service.encrypt(value); encryptedFields.push(fieldConfig.field); } if (encryptedFields.length > 0) { service.logAccess('encrypt', 'write', encryptedFields); } } function decryptRow( row: Record, fields: ModelEncryptionFieldConfig[], service: FieldEncryptionService, ): void { const decryptedFields: string[] = []; for (const fieldConfig of fields) { const value = row[fieldConfig.field]; if (value === undefined || value === null) continue; if (service.isAlreadyEncrypted(value)) { row[fieldConfig.field] = service.decrypt(value); decryptedFields.push(fieldConfig.field); } } if (decryptedFields.length > 0) { service.logAccess('decrypt', 'read', decryptedFields); } } function decryptResult( result: unknown, config: ModelEncryptionConfig, service: FieldEncryptionService, ): void { if (Array.isArray(result)) { for (const item of result) { if (typeof item === 'object' && item !== null) { decryptRow(item as Record, config.fields, service); } } } else if (typeof result === 'object' && result !== null) { decryptRow(result as Record, config.fields, service); } } // --------------------------------------------------------------------------- // Write-args encryption // --------------------------------------------------------------------------- function encryptWriteArgs( args: Record, action: string, config: ModelEncryptionConfig, service: FieldEncryptionService, ): void { if (action === 'createMany' || action === 'createManyAndReturn') { const data = args['data']; if (Array.isArray(data)) { for (const row of data) { encryptDataObject(row as Record, config.fields, service); } } return; } if (action === 'upsert') { const create = args['create'] as Record | undefined; const update = args['update'] as Record | undefined; if (create) encryptDataObject(create, config.fields, service); if (update) encryptDataObject(update, config.fields, service); return; } // create, update, updateMany — args.data const data = args['data'] as Record | undefined; if (data) { encryptDataObject(data, config.fields, service); } } // Prisma actions that write data const WRITE_ACTIONS = new Set([ 'create', 'createMany', 'createManyAndReturn', 'update', 'updateMany', 'upsert', ]); // Prisma actions whose results we should decrypt const READ_ACTIONS = new Set([ 'findUnique', 'findUniqueOrThrow', 'findFirst', 'findFirstOrThrow', 'findMany', 'create', 'createManyAndReturn', 'update', 'upsert', 'delete', ]); // --------------------------------------------------------------------------- // Public: create the Prisma extension // --------------------------------------------------------------------------- /** * Creates a Prisma query extension for field-level encryption. * * Usage inside PrismaService: * ```ts * const extended = prisma.$extends(createEncryptionExtension(service)); * ``` */ export function createEncryptionExtension(service: FieldEncryptionService) { // Build a fast lookup: lowercase model name → config const modelLookup = new Map(); for (const config of service.getFieldMap()) { modelLookup.set(config.model.toLowerCase(), config); } return Prisma.defineExtension({ name: 'field-encryption', query: { $allModels: { async $allOperations({ model, operation, args, query }) { // Look up encryption config for this model const config = model ? modelLookup.get(model.toLowerCase()) : undefined; if (!config || !service.isEnabled()) { return query(args); } // Encrypt on write if (WRITE_ACTIONS.has(operation) && args) { encryptWriteArgs(args as Record, operation, config, service); } const result = await query(args); // Decrypt on read if (READ_ACTIONS.has(operation) && result !== null && result !== undefined) { decryptResult(result, config, service); } return result; }, }, }, }); }