feat(enterprise): add GDPR user/team data purge with audit redaction

This commit is contained in:
SnapOtter
2026-06-13 21:13:22 +08:00
parent b6a8226837
commit dd2a50799a
2 changed files with 302 additions and 0 deletions
+228
View File
@@ -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<void> {
// 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<void> {
// POST /api/v1/enterprise/users/:id/export -- initiate GDPR data export
app.post(
@@ -121,5 +188,166 @@ export async function registerGdprRoutes(app: FastifyInstance): Promise<void> {
},
);
// 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");
}
+74
View File
@@ -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);
});
});