mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add GDPR user/team data purge with audit redaction
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user