Files
goodgo-platform/apps/api/src/modules/shared/infrastructure/encryption-middleware.ts
Ho Ngoc Hai 25420720e7 fix(api,ci): remove type-only imports for DI and isolate CI ports from dev
- 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>
2026-04-13 01:40:14 +07:00

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;
},
},
},
});
}