Add GDPR privacy export and erasure coverage

This commit is contained in:
MOHAN 2026-07-17 22:00:01 +05:30
parent e7549f3dde
commit fabe6e341b
4 changed files with 404 additions and 4 deletions

View File

@ -85,6 +85,16 @@ export class AuthController {
return ok(await this.authService.getProfile(userId)); 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") @Patch("profile")
async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) { async updateProfile(@CurrentUser() userId: string, @Body() payload: UpdateProfileDto) {
return ok(await this.authService.updateProfile(userId, payload)); return ok(await this.authService.updateProfile(userId, payload));

View File

@ -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) { async deleteAccount(userId: string) {
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!user) throw new NotFoundException("User not found."); if (!user) throw new NotFoundException("User not found.");
await this.prisma.$transaction(async (tx) => { 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.account.findMany({ where: { userId }, select: { id: true } }),
tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }), tx.transactionRaw.findMany({ where: { account: { userId } }, select: { id: true } }),
tx.rule.findMany({ where: { userId }, select: { id: true } }), tx.rule.findMany({ where: { userId }, select: { id: true } }),
tx.taxReturn.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 accountIds = accounts.map((account) => account.id);
const transactionIds = rawTransactions.map((transaction) => transaction.id); const transactionIds = rawTransactions.map((transaction) => transaction.id);
const ruleIds = rules.map((rule) => rule.id); const ruleIds = rules.map((rule) => rule.id);
const taxReturnIds = taxReturns.map((taxReturn) => taxReturn.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.taxDocument.deleteMany({ where: { taxReturnId: { in: taxReturnIds } } });
await tx.ruleExecution.deleteMany({ 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.transactionDerived.deleteMany({ where: { rawTransactionId: { in: transactionIds } } });
await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } }); await tx.transactionRaw.deleteMany({ where: { id: { in: transactionIds } } });
await tx.account.deleteMany({ where: { id: { in: accountIds } } }); await tx.account.deleteMany({ where: { id: { in: accountIds } } });
await tx.rule.deleteMany({ where: { userId } }); await tx.rule.deleteMany({ where: { userId } });
await tx.exportDownloadToken.deleteMany({ where: { userId } });
await tx.exportLog.deleteMany({ where: { userId } }); await tx.exportLog.deleteMany({ where: { userId } });
await tx.auditLog.deleteMany({ where: { userId } }); await tx.auditLog.deleteMany({ where: { userId } });
await tx.abuseEvent.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.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.emailVerificationToken.deleteMany({ where: { userId } });
await tx.passwordResetToken.deleteMany({ where: { userId } }); await tx.passwordResetToken.deleteMany({ where: { userId } });
await tx.refreshToken.deleteMany({ where: { userId } }); await tx.refreshToken.deleteMany({ where: { userId } });

View File

@ -88,9 +88,14 @@ export class ComplianceService {
{ {
id: "P1.1", id: "P1.1",
trustServiceCriterion: "Privacy", trustServiceCriterion: "Privacy",
status: "partial", status: "implemented",
control: "User erasure is available through the authenticated account deletion flow.", control: "User erasure, privacy inventory, and minimized personal-data export are available through authenticated account privacy flows.",
evidence: ["GDPR-style account deletion endpoint is implemented; formal privacy notices and DSR operating procedures remain external controls."], 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.",
],
}, },
]; ];

View File

@ -4,6 +4,7 @@ import { AuthService } from "../src/auth/auth.service";
const model = (order: string[]) => ({ const model = (order: string[]) => ({
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
count: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
updateMany: jest.fn(), updateMany: jest.fn(),
@ -20,11 +21,26 @@ const createService = () => {
account: model(order), account: model(order),
transactionRaw: model(order), transactionRaw: model(order),
transactionDerived: model(order), transactionDerived: model(order),
transactionComment: model(order),
rule: model(order), rule: model(order),
ruleExecution: model(order), ruleExecution: model(order),
exportLog: model(order), exportLog: model(order),
exportDownloadToken: model(order),
auditLog: model(order), auditLog: model(order),
apiKey: model(order),
csvImportMapping: model(order),
googleConnection: 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), emailVerificationToken: model(order),
passwordResetToken: model(order), passwordResetToken: model(order),
refreshToken: model(order), refreshToken: model(order),
@ -57,6 +73,24 @@ const createService = () => {
const prisma = { const prisma = {
user: { findUnique: jest.fn() }, 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)), $transaction: jest.fn((callback: (client: typeof tx) => Promise<void>) => callback(tx)),
}; };
const service = new AuthService( const service = new AuthService(
@ -76,6 +110,8 @@ describe("AuthService account deletion", () => {
tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]); tx.transactionRaw.findMany.mockResolvedValue([{ id: "tx_1" }]);
tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]); tx.rule.findMany.mockResolvedValue([{ id: "rule_1" }]);
tx.taxReturn.findMany.mockResolvedValue([{ id: "return_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({ await expect(service.deleteAccount("user_1")).resolves.toEqual({
message: "Account and associated personal data deleted.", 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(tx.user.delete).toHaveBeenCalledWith({ where: { id: "user_1" } });
expect(order.slice(0, 6)).toEqual([ expect(order.slice(0, 6)).toEqual([
"taxDocument.deleteMany", "taxDocument.deleteMany",
@ -108,6 +162,110 @@ describe("AuthService account deletion", () => {
await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException); await expect(service.deleteAccount("missing_user")).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.$transaction).not.toHaveBeenCalled(); 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", () => { describe("AuthService session-bound tokens", () => {