From dd2a50799a255a05e732de5943a7481b91b2c86d Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 13 Jun 2026 21:13:22 +0800 Subject: [PATCH] feat(enterprise): add GDPR user/team data purge with audit redaction --- apps/api/src/routes/enterprise/gdpr.ts | 228 +++++++++++++++++++++++ tests/integration/gdpr-lifecycle.test.ts | 74 ++++++++ 2 files changed, 302 insertions(+) diff --git a/apps/api/src/routes/enterprise/gdpr.ts b/apps/api/src/routes/enterprise/gdpr.ts index 71147567..e12ed28a 100644 --- a/apps/api/src/routes/enterprise/gdpr.ts +++ b/apps/api/src/routes/enterprise/gdpr.ts @@ -1,12 +1,79 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; import { db, schema } from "../../db/index.js"; import { getQueue } from "../../jobs/queues.js"; import { SYSTEM_JOBS } from "../../jobs/system-jobs.js"; import { auditFromRequest } from "../../lib/audit.js"; +import { deleteStoredFile, deleteThumbnail } from "../../lib/file-storage.js"; +import { deletePrefix } from "../../lib/object-storage.js"; import { requirePermission } from "../../permissions.js"; +const purgeBodySchema = z.object({ + confirm: z.literal(true), +}); + +/** + * Purge all data for a single user: files, jobs, audit redaction, sessions, keys, prefs, user row. + * Extracted so it can be reused by the team purge endpoint. + */ +async function purgeUserData(userId: string): Promise { + // a. Delete user's library files from storage + const userFileRows = await db + .select({ storedName: schema.userFiles.storedName }) + .from(schema.userFiles) + .where(eq(schema.userFiles.userId, userId)); + + for (const file of userFileRows) { + await deleteStoredFile(file.storedName); + await deleteThumbnail(file.storedName); + } + + // b. Delete userFiles rows + await db.delete(schema.userFiles).where(eq(schema.userFiles.userId, userId)); + + // c. Delete processing artifacts: workspace objects for each job + const jobRows = await db + .select({ id: schema.jobs.id }) + .from(schema.jobs) + .where(eq(schema.jobs.userId, userId)); + + for (const job of jobRows) { + try { + await deletePrefix(`uploads/${job.id}/`); + } catch { + // Workspace directory may not exist + } + try { + await deletePrefix(`outputs/${job.id}/`); + } catch { + // Workspace directory may not exist + } + } + + // d. Delete jobs rows + await db.delete(schema.jobs).where(eq(schema.jobs.userId, userId)); + + // e. Redact audit log entries (preserve structure, remove PII) + await db + .update(schema.auditLog) + .set({ actorUsername: "[redacted]", ipAddress: null, details: {} }) + .where(eq(schema.auditLog.actorId, userId)); + + // f. Delete sessions + await db.delete(schema.sessions).where(eq(schema.sessions.userId, userId)); + + // g. Delete apiKeys + await db.delete(schema.apiKeys).where(eq(schema.apiKeys.userId, userId)); + + // h. Delete userPreferences + await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId)); + + // i. Delete user row (also cascades pipelines) + await db.delete(schema.users).where(eq(schema.users.id, userId)); +} + export async function registerGdprRoutes(app: FastifyInstance): Promise { // POST /api/v1/enterprise/users/:id/export -- initiate GDPR data export app.post( @@ -121,5 +188,166 @@ export async function registerGdprRoutes(app: FastifyInstance): Promise { }, ); + // DELETE /api/v1/enterprise/users/:id/purge -- GDPR right-to-erasure + app.delete( + "/api/v1/enterprise/users/:id/purge", + async ( + request: FastifyRequest<{ Params: { id: string }; Body: unknown }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("gdpr_lifecycle"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "GDPR data purge requires an enterprise license with the gdpr_lifecycle feature", + }); + } + + // Validate confirmation body + const parsed = purgeBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Purge requires explicit confirmation", + details: parsed.error.issues, + }); + } + + const targetUserId = request.params.id; + + // Check target user exists + const [targetUser] = await db + .select({ + id: schema.users.id, + team: schema.users.team, + legalHold: schema.users.legalHold, + }) + .from(schema.users) + .where(eq(schema.users.id, targetUserId)); + if (!targetUser) { + return reply.status(404).send({ error: "User not found" }); + } + + // Check legal hold on user + if (targetUser.legalHold) { + return reply.status(409).send({ error: "User or team is under legal hold" }); + } + + // Check legal hold on user's team + const [teamRow] = await db + .select({ legalHold: schema.teams.legalHold }) + .from(schema.teams) + .where(eq(schema.teams.id, targetUser.team)); + if (teamRow?.legalHold) { + return reply.status(409).send({ error: "User or team is under legal hold" }); + } + + // Execute purge + await purgeUserData(targetUserId); + + // Emit audit event (admin as actor, target = purged userId) + await auditFromRequest(request)("GDPR_USER_PURGED", { + adminId: user.id, + username: user.username, + targetUserId, + }); + + return reply.send({ success: true, purgedUserId: targetUserId }); + }, + ); + + // DELETE /api/v1/enterprise/teams/:id/purge -- GDPR team-level erasure + app.delete( + "/api/v1/enterprise/teams/:id/purge", + async ( + request: FastifyRequest<{ Params: { id: string }; Body: unknown }>, + reply: FastifyReply, + ) => { + const user = await requirePermission("compliance:manage")(request, reply); + if (!user) return; + + // Enterprise feature gate + let featureEnabled = false; + try { + const { isFeatureEnabled } = await import("@snapotter/enterprise"); + featureEnabled = isFeatureEnabled("gdpr_lifecycle"); + } catch { + // Enterprise package not available + } + if (!featureEnabled) { + return reply.status(403).send({ + error: "GDPR data purge requires an enterprise license with the gdpr_lifecycle feature", + }); + } + + // Validate confirmation body + const parsed = purgeBodySchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Purge requires explicit confirmation", + details: parsed.error.issues, + }); + } + + const targetTeamId = request.params.id; + + // Check team exists + const [team] = await db + .select({ id: schema.teams.id, legalHold: schema.teams.legalHold }) + .from(schema.teams) + .where(eq(schema.teams.id, targetTeamId)); + if (!team) { + return reply.status(404).send({ error: "Team not found" }); + } + + // Check team's legalHold + if (team.legalHold) { + return reply.status(409).send({ error: "Team is under legal hold" }); + } + + // Get all users in the team + const teamUsers = await db + .select({ id: schema.users.id, legalHold: schema.users.legalHold }) + .from(schema.users) + .where(eq(schema.users.team, targetTeamId)); + + // Check none of the users have individual legalHold + const heldUser = teamUsers.find((u) => u.legalHold); + if (heldUser) { + return reply.status(409).send({ error: "A team member is under individual legal hold" }); + } + + // Purge each team member + for (const member of teamUsers) { + await purgeUserData(member.id); + } + + // Delete the team itself + await db.delete(schema.teams).where(eq(schema.teams.id, targetTeamId)); + + // Emit audit event + await auditFromRequest(request)("GDPR_TEAM_PURGED", { + adminId: user.id, + username: user.username, + targetTeamId, + purgedUsers: teamUsers.length, + }); + + return reply.send({ + success: true, + purgedTeamId: targetTeamId, + purgedUsers: teamUsers.length, + }); + }, + ); + app.log.info("Enterprise GDPR routes registered"); } diff --git a/tests/integration/gdpr-lifecycle.test.ts b/tests/integration/gdpr-lifecycle.test.ts index dfe9dd6f..969a98ed 100644 --- a/tests/integration/gdpr-lifecycle.test.ts +++ b/tests/integration/gdpr-lifecycle.test.ts @@ -54,6 +54,15 @@ describe("GDPR data export", () => { expect(res.statusCode).toBe(401); }); + it("returns 401 for DELETE purge without auth", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(401); + }); + it("returns 403 for non-admin user", async () => { // Create a regular user await testApp.app.inject({ @@ -87,3 +96,68 @@ describe("GDPR data export", () => { expect(res.statusCode).toBe(403); }); }); + +describe("GDPR data purge", () => { + it("returns 403 without enterprise license for user purge", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("returns 403 without enterprise license for team purge", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/teams/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(403); + const body = JSON.parse(res.body); + expect(body.error).toContain("enterprise"); + }); + + it("requires confirmation body for purge", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: {}, + }); + // Should fail validation (400 or 403 depending on feature check order) + expect([400, 403]).toContain(res.statusCode); + }); + + it("rejects purge with confirm: false", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + headers: { authorization: `Bearer ${adminToken}` }, + payload: { confirm: false }, + }); + expect([400, 403]).toContain(res.statusCode); + }); + + it("returns 401 for user purge without auth", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/users/some-id/purge", + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(401); + }); + + it("returns 401 for team purge without auth", async () => { + const res = await testApp.app.inject({ + method: "DELETE", + url: "/api/v1/enterprise/teams/some-id/purge", + payload: { confirm: true }, + }); + expect(res.statusCode).toBe(401); + }); +});