Encrypt raw transaction payload storage
This commit is contained in:
parent
6395b63098
commit
1b0f1c08d2
@ -1,11 +1,12 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { EncryptionService } from "./encryption.service";
|
||||
import { OpaqueIdService } from "./opaque-id.service";
|
||||
import { RawPayloadEncryptionService } from "./raw-payload-encryption.service";
|
||||
import { ViewRefService } from "./view-ref.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EncryptionService, OpaqueIdService, ViewRefService],
|
||||
exports: [EncryptionService, OpaqueIdService, ViewRefService],
|
||||
providers: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
||||
exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
37
src/common/raw-payload-encryption.service.ts
Normal file
37
src/common/raw-payload-encryption.service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { EncryptionService } from "./encryption.service";
|
||||
|
||||
type EncryptedRawPayload = {
|
||||
encrypted: true;
|
||||
version: 1;
|
||||
ciphertext: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RawPayloadEncryptionService {
|
||||
constructor(private readonly encryption: EncryptionService) {}
|
||||
|
||||
encrypt(payload: unknown): Prisma.InputJsonValue {
|
||||
return {
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
ciphertext: this.encryption.encrypt(JSON.stringify(payload ?? null)),
|
||||
} satisfies EncryptedRawPayload as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
decrypt<T = unknown>(payload: unknown): T {
|
||||
if (!this.isEncryptedPayload(payload)) return payload as T;
|
||||
return JSON.parse(this.encryption.decrypt(payload.ciphertext)) as T;
|
||||
}
|
||||
|
||||
private isEncryptedPayload(payload: unknown): payload is EncryptedRawPayload {
|
||||
return Boolean(
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
(payload as { encrypted?: unknown }).encrypted === true &&
|
||||
(payload as { version?: unknown }).version === 1 &&
|
||||
typeof (payload as { ciphertext?: unknown }).ciphertext === "string",
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ import * as crypto from "crypto";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
||||
|
||||
@Injectable()
|
||||
@ -22,6 +23,7 @@ export class PlaidService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
private readonly planLimits: PlanLimitsService,
|
||||
private readonly rawPayloads: RawPayloadEncryptionService,
|
||||
) {
|
||||
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
||||
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
||||
@ -204,7 +206,7 @@ export class PlaidService {
|
||||
for (const tx of response.data.transactions) {
|
||||
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
||||
if (!account) continue;
|
||||
const rawPayload = tx as unknown as Prisma.InputJsonValue;
|
||||
const rawPayload = this.rawPayloads.encrypt(tx);
|
||||
await this.prisma.transactionRaw.upsert({
|
||||
where: { bankTransactionId: tx.transaction_id },
|
||||
update: {
|
||||
|
||||
@ -4,6 +4,7 @@ import * as fs from "fs";
|
||||
import * as https from "https";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { EncryptionService } from "../common/encryption.service";
|
||||
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
||||
import { TellerEnrollmentPayload } from "./teller.controller";
|
||||
|
||||
@ -15,6 +16,7 @@ export class TellerService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly encryption: EncryptionService,
|
||||
private readonly planLimits: PlanLimitsService,
|
||||
private readonly rawPayloads: RawPayloadEncryptionService,
|
||||
) {}
|
||||
|
||||
getConnectConfig() {
|
||||
@ -117,7 +119,7 @@ export class TellerService {
|
||||
date: new Date(tx.date),
|
||||
amount: new Prisma.Decimal(tx.amount),
|
||||
description: tx.description ?? "Teller transaction",
|
||||
rawPayload: tx as unknown as Prisma.InputJsonValue,
|
||||
rawPayload: this.rawPayloads.encrypt(tx),
|
||||
source: "teller",
|
||||
ingestedAt: new Date(),
|
||||
},
|
||||
@ -127,7 +129,7 @@ export class TellerService {
|
||||
date: new Date(tx.date),
|
||||
amount: new Prisma.Decimal(tx.amount),
|
||||
description: tx.description ?? "Teller transaction",
|
||||
rawPayload: tx as unknown as Prisma.InputJsonValue,
|
||||
rawPayload: this.rawPayloads.encrypt(tx),
|
||||
source: "teller",
|
||||
},
|
||||
});
|
||||
|
||||
@ -5,6 +5,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { PlaidService } from "../plaid/plaid.service";
|
||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
||||
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
||||
import { ViewRefService } from "../common/view-ref.service";
|
||||
import { ExportsService } from "../exports/exports.service";
|
||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||
@ -155,6 +156,7 @@ export class TransactionsService {
|
||||
private readonly plaidService: PlaidService,
|
||||
private readonly opaqueIds: OpaqueIdService,
|
||||
private readonly viewRefs: ViewRefService,
|
||||
private readonly rawPayloads: RawPayloadEncryptionService,
|
||||
private readonly exportsService?: ExportsService,
|
||||
) {}
|
||||
|
||||
@ -351,7 +353,7 @@ export class TransactionsService {
|
||||
date: dateObj,
|
||||
amount: row.amount,
|
||||
description: row.description,
|
||||
rawPayload: row as unknown as Prisma.InputJsonValue,
|
||||
rawPayload: this.rawPayloads.encrypt(row),
|
||||
ingestedAt: new Date(),
|
||||
source: "csv",
|
||||
},
|
||||
@ -456,7 +458,7 @@ export class TransactionsService {
|
||||
date: new Date(payload.date),
|
||||
amount: payload.amount,
|
||||
description: payload.description,
|
||||
rawPayload: payload as unknown as Prisma.InputJsonValue,
|
||||
rawPayload: this.rawPayloads.encrypt(payload),
|
||||
ingestedAt: new Date(),
|
||||
source: "manual",
|
||||
},
|
||||
|
||||
@ -25,6 +25,9 @@ const createService = () => {
|
||||
encryption: {
|
||||
decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")),
|
||||
},
|
||||
rawPayloads: {
|
||||
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
|
||||
},
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
|
||||
26
test/raw-payload-encryption.service.spec.ts
Normal file
26
test/raw-payload-encryption.service.spec.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { RawPayloadEncryptionService } from "../src/common/raw-payload-encryption.service";
|
||||
|
||||
describe("RawPayloadEncryptionService", () => {
|
||||
it("stores raw transaction payloads as encrypted envelopes", () => {
|
||||
const encryption = {
|
||||
encrypt: jest.fn((value: string) => `cipher:${Buffer.from(value).toString("base64")}`),
|
||||
decrypt: jest.fn((value: string) => Buffer.from(value.replace("cipher:", ""), "base64").toString("utf8")),
|
||||
};
|
||||
const service = new RawPayloadEncryptionService(encryption as never);
|
||||
|
||||
const encrypted = service.encrypt({ transaction_id: "tx_1", merchant: "Coffee" }) as {
|
||||
encrypted: boolean;
|
||||
version: number;
|
||||
ciphertext: string;
|
||||
transaction_id?: string;
|
||||
};
|
||||
|
||||
expect(encrypted).toEqual({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
ciphertext: expect.stringMatching(/^cipher:/),
|
||||
});
|
||||
expect(encrypted).not.toHaveProperty("transaction_id");
|
||||
expect(service.decrypt(encrypted)).toEqual({ transaction_id: "tx_1", merchant: "Coffee" });
|
||||
});
|
||||
});
|
||||
@ -18,6 +18,9 @@ const createService = () => {
|
||||
encrypt: jest.fn((value: string) => `enc_${value}`),
|
||||
decrypt: jest.fn((value: string) => value.replace("enc_", "")),
|
||||
},
|
||||
rawPayloads: {
|
||||
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
|
||||
},
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
},
|
||||
|
||||
@ -10,9 +10,10 @@ const createService = () => {
|
||||
decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")),
|
||||
};
|
||||
const viewRefs = { matches: jest.fn() };
|
||||
const rawPayloads = { encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })) };
|
||||
const exportsService = { syncGoogleSheets: jest.fn().mockResolvedValue({ status: "synced" }) };
|
||||
const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, exportsService as any);
|
||||
return { service, prisma, plaid, opaqueIds, viewRefs, exportsService };
|
||||
const service = new TransactionsService(prisma as any, plaid as any, opaqueIds as any, viewRefs as any, rawPayloads as any, exportsService as any);
|
||||
return { service, prisma, plaid, opaqueIds, viewRefs, rawPayloads, exportsService };
|
||||
};
|
||||
|
||||
describe("TransactionsService", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user