- Remove `type` keyword from NestJS injectable class imports across all modules to fix runtime DI resolution (330+ handler/listener files) - Offset CI docker-compose ports (5433/6380/8109/9002) to avoid conflicts with running dev containers - Update .env.test, playwright.config.ts, and e2e workflow to use isolated CI ports with configurable overrides - Fix prisma/seed.ts to use deterministic IDs for Prisma 7 upsert compatibility (phoneHash replaced phone as unique index) - Add dedicated Docker bridge network for CI service containers Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
198 lines
5.7 KiB
TypeScript
198 lines
5.7 KiB
TypeScript
/**
|
|
* 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<string, unknown>,
|
|
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<string, unknown>,
|
|
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<string, unknown>, config.fields, service);
|
|
}
|
|
}
|
|
} else if (typeof result === 'object' && result !== null) {
|
|
decryptRow(result as Record<string, unknown>, config.fields, service);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Write-args encryption
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function encryptWriteArgs(
|
|
args: Record<string, unknown>,
|
|
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<string, unknown>, config.fields, service);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (action === 'upsert') {
|
|
const create = args['create'] as Record<string, unknown> | undefined;
|
|
const update = args['update'] as Record<string, unknown> | 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<string, unknown> | 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<string, ModelEncryptionConfig>();
|
|
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<string, unknown>, 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;
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|