mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(enterprise): add GDPR user data export (async)
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* GDPR user data export job.
|
||||
*
|
||||
* Collects all user data (profile, files metadata, job history, audit log),
|
||||
* copies library file contents, and produces a ZIP archive stored in
|
||||
* object storage under `outputs/<jobId>/gdpr-export.zip`.
|
||||
*/
|
||||
import { PassThrough } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import archiver from "archiver";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { readStoredFile } from "../lib/file-storage.js";
|
||||
import { putObject } from "../lib/object-storage.js";
|
||||
|
||||
export async function gdprExportJob(userId: string, jobId: string): Promise<{ outputRef: string }> {
|
||||
// 1. Fetch user profile (exclude passwordHash)
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, userId));
|
||||
if (!user) throw new Error(`User ${userId} not found`);
|
||||
|
||||
const { passwordHash: _, ...profile } = user;
|
||||
|
||||
// 2. Fetch all user's files metadata
|
||||
const files = await db.select().from(schema.userFiles).where(eq(schema.userFiles.userId, userId));
|
||||
|
||||
// 3. Fetch job metadata
|
||||
const jobs = await db.select().from(schema.jobs).where(eq(schema.jobs.userId, userId));
|
||||
|
||||
// 4. Fetch audit log entries where user is the actor
|
||||
const auditEntries = await db
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.where(eq(schema.auditLog.actorId, userId));
|
||||
|
||||
// 5. Build a ZIP archive in memory via archiver
|
||||
const archive = archiver("zip", { zlib: { level: 6 } });
|
||||
const chunks: Buffer[] = [];
|
||||
const passthrough = new PassThrough();
|
||||
passthrough.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
|
||||
const pipelineDone = pipeline(archive, passthrough);
|
||||
|
||||
archive.append(JSON.stringify(profile, null, 2), { name: "profile.json" });
|
||||
|
||||
archive.append(
|
||||
JSON.stringify(
|
||||
files.map((f) => ({
|
||||
...f,
|
||||
createdAt: f.createdAt?.toISOString(),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ name: "files.json" },
|
||||
);
|
||||
|
||||
archive.append(
|
||||
JSON.stringify(
|
||||
jobs.map((j) => ({
|
||||
...j,
|
||||
createdAt: j.createdAt?.toISOString(),
|
||||
startedAt: j.startedAt?.toISOString(),
|
||||
completedAt: j.completedAt?.toISOString(),
|
||||
deleteAfter: j.deleteAfter?.toISOString(),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ name: "jobs.json" },
|
||||
);
|
||||
|
||||
archive.append(
|
||||
JSON.stringify(
|
||||
auditEntries.map((a) => ({
|
||||
...a,
|
||||
createdAt: a.createdAt?.toISOString(),
|
||||
})),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ name: "audit-log.json" },
|
||||
);
|
||||
|
||||
// 6. Copy library file contents into the ZIP
|
||||
for (const file of files) {
|
||||
try {
|
||||
const buffer = await readStoredFile(file.storedName);
|
||||
archive.append(buffer, {
|
||||
name: `library-files/${file.id}_${file.originalName}`,
|
||||
});
|
||||
} catch {
|
||||
// File may have been cleaned up; skip silently
|
||||
}
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
await pipelineDone;
|
||||
|
||||
// 7. Write ZIP to object storage
|
||||
const zipBuffer = Buffer.concat(chunks);
|
||||
const outputRef = `outputs/${jobId}/gdpr-export.zip`;
|
||||
await putObject(outputRef, zipBuffer);
|
||||
|
||||
return { outputRef };
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export const SYSTEM_JOBS = {
|
||||
siemForward: "system:siem-forward",
|
||||
auditArchive: "system:audit-archive",
|
||||
storageReconciliation: "system:storage-reconciliation",
|
||||
gdprExport: "system:gdpr-export",
|
||||
} as const;
|
||||
|
||||
// -- Scheduling ---------------------------------------------------------------
|
||||
@@ -80,6 +81,21 @@ export async function runSystemJob(job: Job): Promise<unknown> {
|
||||
const { storageReconciliationJob } = await import("./storage-reconciliation.js");
|
||||
return storageReconciliationJob();
|
||||
}
|
||||
case SYSTEM_JOBS.gdprExport: {
|
||||
const { gdprExportJob } = await import("./gdpr-export.js");
|
||||
const exportData = job.data as unknown as { userId: string; jobId: string };
|
||||
const { outputRef } = await gdprExportJob(exportData.userId, exportData.jobId);
|
||||
// Update the job row with the output reference
|
||||
await db
|
||||
.update(schema.jobs)
|
||||
.set({
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
outputRefs: [outputRef],
|
||||
})
|
||||
.where(eq(schema.jobs.id, exportData.jobId));
|
||||
return { outputRef };
|
||||
}
|
||||
default:
|
||||
// batch-finalize runs on the system pool too but is routed by the
|
||||
// worker before calling runSystemJob. Anything else is a bug.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
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 { requirePermission } from "../../permissions.js";
|
||||
|
||||
export async function registerGdprRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/v1/enterprise/users/:id/export -- initiate GDPR data export
|
||||
app.post(
|
||||
"/api/v1/enterprise/users/:id/export",
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, 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 export requires an enterprise license with the gdpr_lifecycle feature",
|
||||
});
|
||||
}
|
||||
|
||||
const targetUserId = request.params.id;
|
||||
|
||||
// Validate the target user exists
|
||||
const [targetUser] = await db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, targetUserId));
|
||||
if (!targetUser) {
|
||||
return reply.status(404).send({ error: "User not found" });
|
||||
}
|
||||
|
||||
// Create a durable job row
|
||||
const jobId = randomUUID();
|
||||
await db.insert(schema.jobs).values({
|
||||
id: jobId,
|
||||
userId: targetUserId,
|
||||
toolId: "gdpr-export",
|
||||
pool: "system",
|
||||
type: "system",
|
||||
status: "queued",
|
||||
});
|
||||
|
||||
// Enqueue the system job
|
||||
const q = getQueue("system");
|
||||
await q.add(SYSTEM_JOBS.gdprExport, { userId: targetUserId, jobId } as never, { jobId });
|
||||
|
||||
await auditFromRequest(request)("GDPR_EXPORT_INITIATED", {
|
||||
adminId: user.id,
|
||||
username: user.username,
|
||||
targetUserId,
|
||||
jobId,
|
||||
});
|
||||
|
||||
return reply.status(202).send({ jobId, message: "Export started" });
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/v1/enterprise/users/:id/export/:jobId -- check export status
|
||||
app.get(
|
||||
"/api/v1/enterprise/users/:id/export/:jobId",
|
||||
async (
|
||||
request: FastifyRequest<{ Params: { id: string; jobId: string } }>,
|
||||
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 export requires an enterprise license with the gdpr_lifecycle feature",
|
||||
});
|
||||
}
|
||||
|
||||
const { jobId } = request.params;
|
||||
|
||||
const [job] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, jobId));
|
||||
|
||||
if (!job) {
|
||||
return reply.status(404).send({ error: "Export job not found" });
|
||||
}
|
||||
|
||||
if (job.status === "completed") {
|
||||
const outputRef = job.outputRefs?.[0];
|
||||
const filename = outputRef?.split("/").pop() ?? "gdpr-export.zip";
|
||||
return reply.send({
|
||||
status: "completed",
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (job.status === "failed") {
|
||||
const errorMsg = (job.error as { message?: string } | null)?.message ?? "Export failed";
|
||||
return reply.send({ status: "failed", error: errorMsg });
|
||||
}
|
||||
|
||||
// queued or processing
|
||||
const progress = job.progress as { percent?: number; stage?: string } | null;
|
||||
return reply.send({
|
||||
status: job.status,
|
||||
progress: progress?.percent ?? 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.log.info("Enterprise GDPR routes registered");
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { registerAuditExport } from "./audit-export.js";
|
||||
import { registerGdprRoutes } from "./gdpr.js";
|
||||
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
||||
import { registerSiemRoutes } from "./siem.js";
|
||||
|
||||
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
||||
await registerAuditExport(app);
|
||||
await registerGdprRoutes(app);
|
||||
await registerLegalHoldRoutes(app);
|
||||
await registerSiemRoutes(app);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user