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",
|
siemForward: "system:siem-forward",
|
||||||
auditArchive: "system:audit-archive",
|
auditArchive: "system:audit-archive",
|
||||||
storageReconciliation: "system:storage-reconciliation",
|
storageReconciliation: "system:storage-reconciliation",
|
||||||
|
gdprExport: "system:gdpr-export",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// -- Scheduling ---------------------------------------------------------------
|
// -- Scheduling ---------------------------------------------------------------
|
||||||
@@ -80,6 +81,21 @@ export async function runSystemJob(job: Job): Promise<unknown> {
|
|||||||
const { storageReconciliationJob } = await import("./storage-reconciliation.js");
|
const { storageReconciliationJob } = await import("./storage-reconciliation.js");
|
||||||
return storageReconciliationJob();
|
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:
|
default:
|
||||||
// batch-finalize runs on the system pool too but is routed by the
|
// batch-finalize runs on the system pool too but is routed by the
|
||||||
// worker before calling runSystemJob. Anything else is a bug.
|
// 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 type { FastifyInstance } from "fastify";
|
||||||
import { registerAuditExport } from "./audit-export.js";
|
import { registerAuditExport } from "./audit-export.js";
|
||||||
|
import { registerGdprRoutes } from "./gdpr.js";
|
||||||
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
import { registerLegalHoldRoutes } from "./legal-hold.js";
|
||||||
import { registerSiemRoutes } from "./siem.js";
|
import { registerSiemRoutes } from "./siem.js";
|
||||||
|
|
||||||
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
export async function registerEnterpriseRoutes(app: FastifyInstance) {
|
||||||
await registerAuditExport(app);
|
await registerAuditExport(app);
|
||||||
|
await registerGdprRoutes(app);
|
||||||
await registerLegalHoldRoutes(app);
|
await registerLegalHoldRoutes(app);
|
||||||
await registerSiemRoutes(app);
|
await registerSiemRoutes(app);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user