Add GDPR privacy export and erasure coverage
This commit is contained in:
parent
e7549f3dde
commit
fabe6e341b
@ -85,6 +85,16 @@ export class AuthController {
|
||||
return ok(await this.authService.getProfile(userId));
|
||||
}
|
||||
|
||||
@Get("me/privacy-summary")
|
||||
async privacySummary(@CurrentUser() userId: string) {
|
||||
return ok(await this.authService.getPrivacySummary(userId));
|
||||
}
|
||||
|
||||
@Get("me/data-export")
|
||||
async dataExport(@CurrentUser() userId: string) {
|
||||
return ok(await this.authService.exportPersonalData(userId));
|
||||
}
|
||||
|
||||
@Patch("profile")
|
||||
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
|
||||
return ok(await this.authService.updateProfile(userId, payload));
|
||||
|
||||
@ -213,22 +213,208 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async getPrivacySummary(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, email: true, emailVerified: true, twoFactorEnabled: true, createdAt: true },
|
||||
});
|
||||
if (!user) throw new NotFoundException("User not found.");
|
||||
|
||||
const [
|
||||
accounts,
|
||||
transactions,
|
||||
transactionComments,
|
||||
rules,
|
||||
taxReturns,
|
||||
exports,
|
||||
exportDownloadTokens,
|
||||
apiKeys,
|
||||
csvImportMappings,
|
||||
notifications,
|
||||
pushSubscriptions,
|
||||
bills,
|
||||
billPayees,
|
||||
billPayments,
|
||||
creditScores,
|
||||
householdMemberships,
|
||||
householdInvites,
|
||||
householdGoals,
|
||||
] = await Promise.all([
|
||||
this.prisma.account.count({ where: { userId } }),
|
||||
this.prisma.transactionRaw.count({ where: { account: { userId } } }),
|
||||
this.prisma.transactionComment.count({ where: { userId } }),
|
||||
this.prisma.rule.count({ where: { userId } }),
|
||||
this.prisma.taxReturn.count({ where: { userId } }),
|
||||
this.prisma.exportLog.count({ where: { userId } }),
|
||||
this.prisma.exportDownloadToken.count({ where: { userId } }),
|
||||
this.prisma.apiKey.count({ where: { userId } }),
|
||||
this.prisma.csvImportMapping.count({ where: { userId } }),
|
||||
this.prisma.notification.count({ where: { userId } }),
|
||||
this.prisma.pushSubscription.count({ where: { userId } }),
|
||||
this.prisma.bill.count({ where: { userId } }),
|
||||
this.prisma.billPayee.count({ where: { userId } }),
|
||||
this.prisma.billPayment.count({ where: { userId } }),
|
||||
this.prisma.creditScoreEntry.count({ where: { userId } }),
|
||||
this.prisma.householdMember.count({ where: { userId } }),
|
||||
this.prisma.householdInvite.count({ where: { OR: [{ invitedById: userId }, { acceptedById: userId }] } }),
|
||||
this.prisma.householdGoal.count({ where: { createdByUserId: userId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
subject: user,
|
||||
dataCategories: {
|
||||
accounts,
|
||||
transactions,
|
||||
transactionComments,
|
||||
rules,
|
||||
taxReturns,
|
||||
exports,
|
||||
exportDownloadTokens,
|
||||
apiKeys,
|
||||
csvImportMappings,
|
||||
notifications,
|
||||
pushSubscriptions,
|
||||
bills,
|
||||
billPayees,
|
||||
billPayments,
|
||||
creditScores,
|
||||
householdMemberships,
|
||||
householdInvites,
|
||||
householdGoals,
|
||||
},
|
||||
controls: {
|
||||
exportEndpoint: "/api/auth/me/data-export",
|
||||
deletionEndpoint: "/api/auth/me",
|
||||
excludedFromExport: [
|
||||
"passwordHash",
|
||||
"twoFactorSecret",
|
||||
"session and refresh tokens",
|
||||
"OAuth tokens",
|
||||
"API key hashes",
|
||||
"bank access tokens",
|
||||
"raw bank payloads",
|
||||
"stable internal account and transaction IDs",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async exportPersonalData(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
fullName: true,
|
||||
phone: true,
|
||||
companyName: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
city: true,
|
||||
state: true,
|
||||
postalCode: true,
|
||||
country: true,
|
||||
role: true,
|
||||
emailVerified: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException("User not found.");
|
||||
|
||||
const [accounts, transactions, rules, exports, apiKeys, taxReturns] = await Promise.all([
|
||||
this.prisma.account.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
institutionName: true,
|
||||
accountType: true,
|
||||
mask: true,
|
||||
ownershipType: true,
|
||||
currentBalance: true,
|
||||
availableBalance: true,
|
||||
isoCurrencyCode: true,
|
||||
syncStatus: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.transactionRaw.findMany({
|
||||
where: { account: { userId } },
|
||||
select: {
|
||||
date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
source: true,
|
||||
ingestedAt: true,
|
||||
derived: {
|
||||
select: {
|
||||
userCategory: true,
|
||||
userNotes: true,
|
||||
attribution: true,
|
||||
splitMode: true,
|
||||
splitMinePercent: true,
|
||||
splitYoursPercent: true,
|
||||
isHidden: true,
|
||||
modifiedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
}),
|
||||
this.prisma.rule.findMany({
|
||||
where: { userId },
|
||||
select: { name: true, priority: true, conditions: true, actions: true, isActive: true, createdAt: true },
|
||||
orderBy: { priority: "asc" },
|
||||
}),
|
||||
this.prisma.exportLog.findMany({
|
||||
where: { userId },
|
||||
select: { format: true, destination: true, filters: true, rowCount: true, fileName: true, mimeType: true, fileHash: true, metadata: true, createdAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
this.prisma.apiKey.findMany({
|
||||
where: { userId },
|
||||
select: { name: true, prefix: true, scopes: true, lastUsedAt: true, revokedAt: true, expiresAt: true, createdAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
this.prisma.taxReturn.findMany({
|
||||
where: { userId },
|
||||
select: { taxYear: true, filingType: true, jurisdictions: true, status: true, summary: true, createdAt: true, updatedAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
subject: user,
|
||||
minimizationNotes: [
|
||||
"Secrets, raw bank payloads, access tokens, token hashes, and stable internal account/transaction IDs are excluded.",
|
||||
],
|
||||
data: { accounts, transactions, rules, exports, apiKeys, taxReturns },
|
||||
};
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
|
||||
if (!user) throw new NotFoundException("User not found.");
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const [accounts, rawTransactions, rules, taxReturns] = await Promise.all([
|
||||
const [accounts, rawTransactions, rules, taxReturns, bills, createdHouseholds] = await Promise.all([
|
||||
tx.account.findMany({ where: { userId }, select: { id: true } }),
|
||||
tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }),
|
||||
tx.rule.findMany({ where: { userId }, select: { id: true } }),
|
||||
tx.taxReturn.findMany({ where: { userId }, select: { id: true } }),
|
||||
tx.bill.findMany({ where: { userId }, select: { id: true } }),
|
||||
tx.household.findMany({ where: { createdByUserId: userId }, select: { id: true } }),
|
||||
]);
|
||||
|
||||
const accountIds = accounts.map((account) => account.id);
|
||||
const transactionIds = rawTransactions.map((transaction) => transaction.id);
|
||||
const ruleIds = rules.map((rule) => rule.id);
|
||||
const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.id);
|
||||
const billIds = bills.map((bill) => bill.id);
|
||||
const createdHouseholdIds = createdHouseholds.map((household) => household.id);
|
||||
|
||||
await tx.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
|
||||
await tx.ruleExecution.deleteMany({
|
||||
@ -239,14 +425,55 @@ export class AuthService {
|
||||
],
|
||||
},
|
||||
});
|
||||
await tx.transactionComment.deleteMany({
|
||||
where: { OR: [{ userId }, { rawTransactionId: { in: transactionIds } }] },
|
||||
});
|
||||
await tx.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } });
|
||||
await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } });
|
||||
await tx.account.deleteMany({ where: { id: { in: accountIds } } });
|
||||
await tx.rule.deleteMany({ where: { userId } });
|
||||
await tx.exportDownloadToken.deleteMany({ where: { userId } });
|
||||
await tx.exportLog.deleteMany({ where: { userId } });
|
||||
await tx.auditLog.deleteMany({ where: { userId } });
|
||||
await tx.abuseEvent.deleteMany({ where: { userId } });
|
||||
await tx.apiKey.deleteMany({ where: { userId } });
|
||||
await tx.csvImportMapping.deleteMany({ where: { userId } });
|
||||
await tx.googleConnection.deleteMany({ where: { userId } });
|
||||
await tx.notificationPreference.deleteMany({ where: { userId } });
|
||||
await tx.notification.deleteMany({ where: { userId } });
|
||||
await tx.pushSubscription.deleteMany({ where: { userId } });
|
||||
await tx.billPayment.deleteMany({
|
||||
where: { OR: [{ userId }, { billId: { in: billIds } }] },
|
||||
});
|
||||
await tx.bill.deleteMany({ where: { userId } });
|
||||
await tx.billPayee.deleteMany({ where: { userId } });
|
||||
await tx.creditScoreEntry.deleteMany({ where: { userId } });
|
||||
await tx.householdInvite.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ invitedById: userId },
|
||||
{ acceptedById: userId },
|
||||
{ householdId: { in: createdHouseholdIds } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await tx.householdGoal.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ createdByUserId: userId },
|
||||
{ householdId: { in: createdHouseholdIds } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await tx.householdMember.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId },
|
||||
{ householdId: { in: createdHouseholdIds } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await tx.household.deleteMany({ where: { id: { in: createdHouseholdIds } } });
|
||||
await tx.emailVerificationToken.deleteMany({ where: { userId } });
|
||||
await tx.passwordResetToken.deleteMany({ where: { userId } });
|
||||
await tx.refreshToken.deleteMany({ where: { userId } });
|
||||
|
||||
@ -88,9 +88,14 @@ export class ComplianceService {
|
||||
{
|
||||
id: "P1.1",
|
||||
trustServiceCriterion: "Privacy",
|
||||
status: "partial",
|
||||
control: "User erasure is available through the authenticated account deletion flow.",
|
||||
evidence: ["GDPR-style account deletion endpoint is implemented; formal privacy notices and DSR operating procedures remain external controls."],
|
||||
status: "implemented",
|
||||
control: "User erasure, privacy inventory, and minimized personal-data export are available through authenticated account privacy flows.",
|
||||
evidence: [
|
||||
"DELETE /api/auth/me erases the account and associated user-owned application data in one transaction.",
|
||||
"GET /api/auth/me/privacy-summary reports user-owned data categories and available controls.",
|
||||
"GET /api/auth/me/data-export returns a minimized JSON export that excludes secrets, token hashes, raw bank payloads, and stable internal account/transaction IDs.",
|
||||
"Formal privacy notices and DSR operating procedures remain external controls.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { AuthService } from "../src/auth/auth.service";
|
||||
const model = (order: string[]) => ({
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
count: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
@ -20,11 +21,26 @@ const createService = () => {
|
||||
account: model(order),
|
||||
transactionRaw: model(order),
|
||||
transactionDerived: model(order),
|
||||
transactionComment: model(order),
|
||||
rule: model(order),
|
||||
ruleExecution: model(order),
|
||||
exportLog: model(order),
|
||||
exportDownloadToken: model(order),
|
||||
auditLog: model(order),
|
||||
apiKey: model(order),
|
||||
csvImportMapping: model(order),
|
||||
googleConnection: model(order),
|
||||
notificationPreference: model(order),
|
||||
notification: model(order),
|
||||
pushSubscription: model(order),
|
||||
billPayee: model(order),
|
||||
bill: model(order),
|
||||
billPayment: model(order),
|
||||
creditScoreEntry: model(order),
|
||||
household: model(order),
|
||||
householdInvite: model(order),
|
||||
householdGoal: model(order),
|
||||
householdMember: model(order),
|
||||
emailVerificationToken: model(order),
|
||||
passwordResetToken: model(order),
|
||||
refreshToken: model(order),
|
||||
@ -57,6 +73,24 @@ const createService = () => {
|
||||
|
||||
const prisma = {
|
||||
user: { findUnique: jest.fn() },
|
||||
account: model(order),
|
||||
transactionRaw: model(order),
|
||||
transactionComment: model(order),
|
||||
rule: model(order),
|
||||
taxReturn: model(order),
|
||||
exportLog: model(order),
|
||||
exportDownloadToken: model(order),
|
||||
apiKey: model(order),
|
||||
csvImportMapping: model(order),
|
||||
notification: model(order),
|
||||
pushSubscription: model(order),
|
||||
bill: model(order),
|
||||
billPayee: model(order),
|
||||
billPayment: model(order),
|
||||
creditScoreEntry: model(order),
|
||||
householdMember: model(order),
|
||||
householdInvite: model(order),
|
||||
householdGoal: model(order),
|
||||
$transaction: jest.fn((callback: (client: typeof tx) => Promise<void>) => callback(tx)),
|
||||
};
|
||||
const service = new AuthService(
|
||||
@ -76,6 +110,8 @@ describe("AuthService account deletion", () => {
|
||||
tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]);
|
||||
tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]);
|
||||
tx.taxReturn.findMany.mockResolvedValue([{ id: "return_1" }]);
|
||||
tx.bill.findMany.mockResolvedValue([{ id: "bill_1" }]);
|
||||
tx.household.findMany.mockResolvedValue([{ id: "household_1" }]);
|
||||
|
||||
await expect(service.deleteAccount("user_1")).resolves.toEqual({
|
||||
message: "Account and associated personal data deleted.",
|
||||
@ -89,6 +125,24 @@ describe("AuthService account deletion", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(tx.transactionComment.deleteMany).toHaveBeenCalledWith({
|
||||
where: { OR: [{ userId: "user_1" }, { rawTransactionId: { in: ["tx_1"] } }] },
|
||||
});
|
||||
expect(tx.exportDownloadToken.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||
expect(tx.apiKey.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||
expect(tx.csvImportMapping.deleteMany).toHaveBeenCalledWith({ where: { userId: "user_1" } });
|
||||
expect(tx.billPayment.deleteMany).toHaveBeenCalledWith({
|
||||
where: { OR: [{ userId: "user_1" }, { billId: { in: ["bill_1"] } }] },
|
||||
});
|
||||
expect(tx.householdInvite.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ invitedById: "user_1" },
|
||||
{ acceptedById: "user_1" },
|
||||
{ householdId: { in: ["household_1"] } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(tx.user.delete).toHaveBeenCalledWith({ where: { id: "user_1" } });
|
||||
expect(order.slice(0, 6)).toEqual([
|
||||
"taxDocument.deleteMany",
|
||||
@ -108,6 +162,110 @@ describe("AuthService account deletion", () => {
|
||||
await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns privacy summary counts for user-owned data", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
id: "user_1",
|
||||
email: "owner@example.com",
|
||||
emailVerified: true,
|
||||
twoFactorEnabled: false,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
[
|
||||
prisma.account,
|
||||
prisma.transactionRaw,
|
||||
prisma.transactionComment,
|
||||
prisma.rule,
|
||||
prisma.taxReturn,
|
||||
prisma.exportLog,
|
||||
prisma.exportDownloadToken,
|
||||
prisma.apiKey,
|
||||
prisma.csvImportMapping,
|
||||
prisma.notification,
|
||||
prisma.pushSubscription,
|
||||
prisma.bill,
|
||||
prisma.billPayee,
|
||||
prisma.billPayment,
|
||||
prisma.creditScoreEntry,
|
||||
prisma.householdMember,
|
||||
prisma.householdInvite,
|
||||
prisma.householdGoal,
|
||||
].forEach((entry, index) => entry.count.mockResolvedValue(index + 1));
|
||||
|
||||
await expect(service.getPrivacySummary("user_1")).resolves.toMatchObject({
|
||||
subject: { id: "user_1", email: "owner@example.com" },
|
||||
dataCategories: {
|
||||
accounts: 1,
|
||||
transactions: 2,
|
||||
transactionComments: 3,
|
||||
rules: 4,
|
||||
taxReturns: 5,
|
||||
exports: 6,
|
||||
exportDownloadTokens: 7,
|
||||
apiKeys: 8,
|
||||
csvImportMappings: 9,
|
||||
notifications: 10,
|
||||
pushSubscriptions: 11,
|
||||
bills: 12,
|
||||
billPayees: 13,
|
||||
billPayments: 14,
|
||||
creditScores: 15,
|
||||
householdMemberships: 16,
|
||||
householdInvites: 17,
|
||||
householdGoals: 18,
|
||||
},
|
||||
controls: {
|
||||
exportEndpoint: "/api/auth/me/data-export",
|
||||
deletionEndpoint: "/api/auth/me",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("exports personal data without secrets or raw bank payloads", async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.user.findUnique.mockResolvedValue({
|
||||
id: "user_1",
|
||||
email: "owner@example.com",
|
||||
fullName: "Owner",
|
||||
phone: null,
|
||||
companyName: null,
|
||||
addressLine1: null,
|
||||
addressLine2: null,
|
||||
city: null,
|
||||
state: null,
|
||||
postalCode: null,
|
||||
country: null,
|
||||
role: "user",
|
||||
emailVerified: true,
|
||||
twoFactorEnabled: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
});
|
||||
[
|
||||
prisma.account,
|
||||
prisma.transactionRaw,
|
||||
prisma.rule,
|
||||
prisma.exportLog,
|
||||
prisma.apiKey,
|
||||
prisma.taxReturn,
|
||||
].forEach((entry) => entry.findMany.mockResolvedValue([]));
|
||||
|
||||
const result = await service.exportPersonalData("user_1");
|
||||
|
||||
expect(result.subject).not.toHaveProperty("passwordHash");
|
||||
expect(result.subject).not.toHaveProperty("twoFactorSecret");
|
||||
expect(prisma.account.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
select: expect.not.objectContaining({ plaidAccessToken: true, tellerAccessToken: true }),
|
||||
}));
|
||||
expect(prisma.transactionRaw.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
select: expect.not.objectContaining({ id: true, rawPayload: true, bankTransactionId: true }),
|
||||
}));
|
||||
expect(prisma.apiKey.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
select: expect.not.objectContaining({ keyHash: true }),
|
||||
}));
|
||||
expect(result.minimizationNotes.join(" ")).toContain("raw bank payloads");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthService session-bound tokens", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user