feat(enterprise): add GDPR user data export (async)

This commit is contained in:
SnapOtter
2026-06-13 21:07:28 +08:00
parent fa7da7ce0c
commit b6a8226837
5 changed files with 337 additions and 0 deletions
+105
View File
@@ -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 };
}
+16
View File
@@ -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.
+125
View File
@@ -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");
}
+2
View File
@@ -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);
}
+89
View File
@@ -0,0 +1,89 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
describe("GDPR data export", () => {
it("returns 403 for POST without enterprise license", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/users/some-id/export",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 403 for GET without enterprise license", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/users/some-id/export/some-job-id",
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.error).toContain("enterprise");
});
it("returns 401 for POST without auth", async () => {
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/users/some-id/export",
});
expect(res.statusCode).toBe(401);
});
it("returns 401 for GET without auth", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/v1/enterprise/users/some-id/export/some-job-id",
});
expect(res.statusCode).toBe(401);
});
it("returns 403 for non-admin user", async () => {
// Create a regular user
await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
username: "gdprexportuser",
password: "TestPass1",
role: "user",
},
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, "gdprexportuser"));
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: "gdprexportuser", password: "TestPass1" },
});
const userToken = JSON.parse(loginRes.body).token;
const res = await testApp.app.inject({
method: "POST",
url: "/api/v1/enterprise/users/some-id/export",
headers: { authorization: `Bearer ${userToken}` },
});
// Regular users lack compliance:manage, so they get 403 before the enterprise check
expect(res.statusCode).toBe(403);
});
});