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 { Global, Module } from "@nestjs/common";
|
||||||
import { EncryptionService } from "./encryption.service";
|
import { EncryptionService } from "./encryption.service";
|
||||||
import { OpaqueIdService } from "./opaque-id.service";
|
import { OpaqueIdService } from "./opaque-id.service";
|
||||||
|
import { RawPayloadEncryptionService } from "./raw-payload-encryption.service";
|
||||||
import { ViewRefService } from "./view-ref.service";
|
import { ViewRefService } from "./view-ref.service";
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [EncryptionService, OpaqueIdService, ViewRefService],
|
providers: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
||||||
exports: [EncryptionService, OpaqueIdService, ViewRefService],
|
exports: [EncryptionService, OpaqueIdService, RawPayloadEncryptionService, ViewRefService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
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 { Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { EncryptionService } from "../common/encryption.service";
|
import { EncryptionService } from "../common/encryption.service";
|
||||||
|
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
||||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -22,6 +23,7 @@ export class PlaidService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly encryption: EncryptionService,
|
private readonly encryption: EncryptionService,
|
||||||
private readonly planLimits: PlanLimitsService,
|
private readonly planLimits: PlanLimitsService,
|
||||||
|
private readonly rawPayloads: RawPayloadEncryptionService,
|
||||||
) {
|
) {
|
||||||
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
const env = (process.env.PLAID_ENV ?? "sandbox") as keyof typeof PlaidEnvironments;
|
||||||
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
const clientId = this.requireEnv("PLAID_CLIENT_ID");
|
||||||
@ -204,7 +206,7 @@ export class PlaidService {
|
|||||||
for (const tx of response.data.transactions) {
|
for (const tx of response.data.transactions) {
|
||||||
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
const account = accounts.find((acct) => acct.plaidAccountId === tx.account_id);
|
||||||
if (!account) continue;
|
if (!account) continue;
|
||||||
const rawPayload = tx as unknown as Prisma.InputJsonValue;
|
const rawPayload = this.rawPayloads.encrypt(tx);
|
||||||
await this.prisma.transactionRaw.upsert({
|
await this.prisma.transactionRaw.upsert({
|
||||||
where: { bankTransactionId: tx.transaction_id },
|
where: { bankTransactionId: tx.transaction_id },
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import * as fs from "fs";
|
|||||||
import * as https from "https";
|
import * as https from "https";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { EncryptionService } from "../common/encryption.service";
|
import { EncryptionService } from "../common/encryption.service";
|
||||||
|
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
||||||
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
import { PlanLimitsService } from "../stripe/plan-limits.service";
|
||||||
import { TellerEnrollmentPayload } from "./teller.controller";
|
import { TellerEnrollmentPayload } from "./teller.controller";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ export class TellerService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly encryption: EncryptionService,
|
private readonly encryption: EncryptionService,
|
||||||
private readonly planLimits: PlanLimitsService,
|
private readonly planLimits: PlanLimitsService,
|
||||||
|
private readonly rawPayloads: RawPayloadEncryptionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getConnectConfig() {
|
getConnectConfig() {
|
||||||
@ -117,7 +119,7 @@ export class TellerService {
|
|||||||
date: new Date(tx.date),
|
date: new Date(tx.date),
|
||||||
amount: new Prisma.Decimal(tx.amount),
|
amount: new Prisma.Decimal(tx.amount),
|
||||||
description: tx.description ?? "Teller transaction",
|
description: tx.description ?? "Teller transaction",
|
||||||
rawPayload: tx as unknown as Prisma.InputJsonValue,
|
rawPayload: this.rawPayloads.encrypt(tx),
|
||||||
source: "teller",
|
source: "teller",
|
||||||
ingestedAt: new Date(),
|
ingestedAt: new Date(),
|
||||||
},
|
},
|
||||||
@ -127,7 +129,7 @@ export class TellerService {
|
|||||||
date: new Date(tx.date),
|
date: new Date(tx.date),
|
||||||
amount: new Prisma.Decimal(tx.amount),
|
amount: new Prisma.Decimal(tx.amount),
|
||||||
description: tx.description ?? "Teller transaction",
|
description: tx.description ?? "Teller transaction",
|
||||||
rawPayload: tx as unknown as Prisma.InputJsonValue,
|
rawPayload: this.rawPayloads.encrypt(tx),
|
||||||
source: "teller",
|
source: "teller",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { Prisma } from "@prisma/client";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { PlaidService } from "../plaid/plaid.service";
|
import { PlaidService } from "../plaid/plaid.service";
|
||||||
import { OpaqueIdService } from "../common/opaque-id.service";
|
import { OpaqueIdService } from "../common/opaque-id.service";
|
||||||
|
import { RawPayloadEncryptionService } from "../common/raw-payload-encryption.service";
|
||||||
import { ViewRefService } from "../common/view-ref.service";
|
import { ViewRefService } from "../common/view-ref.service";
|
||||||
import { ExportsService } from "../exports/exports.service";
|
import { ExportsService } from "../exports/exports.service";
|
||||||
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
import { UpdateDerivedDto } from "./dto/update-derived.dto";
|
||||||
@ -155,6 +156,7 @@ export class TransactionsService {
|
|||||||
private readonly plaidService: PlaidService,
|
private readonly plaidService: PlaidService,
|
||||||
private readonly opaqueIds: OpaqueIdService,
|
private readonly opaqueIds: OpaqueIdService,
|
||||||
private readonly viewRefs: ViewRefService,
|
private readonly viewRefs: ViewRefService,
|
||||||
|
private readonly rawPayloads: RawPayloadEncryptionService,
|
||||||
private readonly exportsService?: ExportsService,
|
private readonly exportsService?: ExportsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -351,7 +353,7 @@ export class TransactionsService {
|
|||||||
date: dateObj,
|
date: dateObj,
|
||||||
amount: row.amount,
|
amount: row.amount,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
rawPayload: row as unknown as Prisma.InputJsonValue,
|
rawPayload: this.rawPayloads.encrypt(row),
|
||||||
ingestedAt: new Date(),
|
ingestedAt: new Date(),
|
||||||
source: "csv",
|
source: "csv",
|
||||||
},
|
},
|
||||||
@ -456,7 +458,7 @@ export class TransactionsService {
|
|||||||
date: new Date(payload.date),
|
date: new Date(payload.date),
|
||||||
amount: payload.amount,
|
amount: payload.amount,
|
||||||
description: payload.description,
|
description: payload.description,
|
||||||
rawPayload: payload as unknown as Prisma.InputJsonValue,
|
rawPayload: this.rawPayloads.encrypt(payload),
|
||||||
ingestedAt: new Date(),
|
ingestedAt: new Date(),
|
||||||
source: "manual",
|
source: "manual",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -25,6 +25,9 @@ const createService = () => {
|
|||||||
encryption: {
|
encryption: {
|
||||||
decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")),
|
decrypt: jest.fn((value: string) => value.replace("enc_", "raw_")),
|
||||||
},
|
},
|
||||||
|
rawPayloads: {
|
||||||
|
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
|
||||||
|
},
|
||||||
logger: {
|
logger: {
|
||||||
error: jest.fn(),
|
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}`),
|
encrypt: jest.fn((value: string) => `enc_${value}`),
|
||||||
decrypt: jest.fn((value: string) => value.replace("enc_", "")),
|
decrypt: jest.fn((value: string) => value.replace("enc_", "")),
|
||||||
},
|
},
|
||||||
|
rawPayloads: {
|
||||||
|
encrypt: jest.fn((payload: unknown) => ({ encrypted: true, version: 1, ciphertext: JSON.stringify(payload) })),
|
||||||
|
},
|
||||||
logger: {
|
logger: {
|
||||||
warn: jest.fn(),
|
warn: jest.fn(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,9 +10,10 @@ const createService = () => {
|
|||||||
decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")),
|
decode: jest.fn((_kind: string, _userId: string, token: string) => token.replace(/^opaque_[^_]+_/, "")),
|
||||||
};
|
};
|
||||||
const viewRefs = { matches: jest.fn() };
|
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 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);
|
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, exportsService };
|
return { service, prisma, plaid, opaqueIds, viewRefs, rawPayloads, exportsService };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("TransactionsService", () => {
|
describe("TransactionsService", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user