feat: add MFA/TOTP auth, PII encryption, agents/leads/inquiries modules, and comprehensive tests
- Add TOTP-based MFA with setup, verify, disable, backup codes, and challenge flow - Add PII field encryption middleware with AES-256-GCM and deterministic search hashes - Add agents, inquiries, and leads domain modules with entities, events, value objects - Add web dashboard pages for inquiries and leads with detail dialogs - Add 30+ component tests (valuation, charts, listings, search, providers, UI) - Add Prisma migrations for encryption hash columns and MFA TOTP support - Fix all ESLint errors (unused imports, duplicate imports, lint auto-fixes) - Update dependencies and lock file - Clean up obsolete exploration/QA docs, add audit documentation Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 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 {
|
||||
type 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;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user