From 28908d5257b72cfd43b397861d0b9094b883994b Mon Sep 17 00:00:00 2001 From: "Sven (AAMOS AI)" Date: Sat, 8 Aug 2026 04:20:03 +0700 Subject: [PATCH] fix(gdpr): explicit radering av ai_corrections/scan_jobs + S3-bilder vid DELETE /v1/me MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /v1/me soft-deletar users, så users-cascade fyrar aldrig. - Samlar distinkta bildnycklar från ai_corrections, ai_training_bank och scan_jobs och raderar dem via storage.deleteObject innan DB-radering. - S3-fel loggas och avbryter inte raderingen. - Raderar explicit ai_corrections (ai_training_bank cascadar) och scan_jobs. - Lägger till deleteObject i StorageService (mock + AWS/DeleteObjectCommand). - Uppdaterar docs/28-lärande-loop.md med faktisk mekanism och retention. - Tester: verifierar noll rader kvar och storage.deleteObject-anrop. Relaterat: skiva-1-fixrunda, blockerande GDPR-hål. --- apps/api/src/plugins/storage.ts | 22 +++- apps/api/src/routes/me.ts | 65 +++++++++- apps/api/test/me.test.ts | 216 ++++++++++++++++++++++++++++++++ docs/28-lärande-loop.md | 39 ++++-- 4 files changed, 327 insertions(+), 15 deletions(-) create mode 100644 apps/api/test/me.test.ts diff --git a/apps/api/src/plugins/storage.ts b/apps/api/src/plugins/storage.ts index 67cd48a..9186840 100644 --- a/apps/api/src/plugins/storage.ts +++ b/apps/api/src/plugins/storage.ts @@ -1,7 +1,7 @@ import fp from "fastify-plugin"; import type { FastifyInstance } from "fastify"; import { createHmac, randomUUID } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; /** @@ -19,6 +19,7 @@ import path from "node:path"; * meal-scans/, receipts/, product-images/, recipe-images/, temporary/. */ import { + DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, @@ -40,6 +41,8 @@ export interface StorageService { getReadUrl(key: string): Promise; putObject(key: string, data: Buffer, contentType: string): Promise; getObject(key: string): Promise; + /** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */ + deleteObject(key: string): Promise; } declare module "fastify" { @@ -91,6 +94,11 @@ class MockStorage implements StorageService { } } + async deleteObject(key: string): Promise { + // GDPR-radering får inte avbrytas av saknade filer; rm med force swallowar felet. + await rm(path.join(MOCK_ROOT, key), { force: true }).catch(() => {}); + } + verifySignature(key: string, sig: string): boolean { return this.sign(key) === sig; } @@ -157,6 +165,18 @@ export class AwsStorage implements StorageService { return null; } } + + async deleteObject(key: string): Promise { + try { + await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); + } catch (err) { + const code = (err as { Code?: string }).Code; + // NoSuchKey ska inte avbryta GDPR-radering; andra fel loggas. + if (code !== "NoSuchKey" && code !== "NotFound") { + throw err; + } + } + } } export const storagePlugin = fp(async (app: FastifyInstance) => { diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 2fad5c1..3eaf6a3 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { deleteUserAnalyticsEvents, schema } from "@app/database"; import { consentInputSchema, @@ -307,8 +307,67 @@ export async function meRoutes(app: FastifyInstance) { // --- GDPR: radera konto (spec §56, §32) --- app.delete("/v1/me", auth, async (req) => { const userId = req.userId; - // Hård radering av persondata via FK-cascade; users-raden anonymiseras - // och soft-deletas för att bevara referensintegritet i aggregat. + + // Eftersom users-raden soft-deletas (anonymiseras) fyras INTE users.onDelete + // cascade på ai_corrections / scan_jobs. Vi måste explicit radera träningsdata + // och skanningar, plus tillhörande bilder i objektlagring. + const correctionRows = await app.db + .select({ id: schema.aiCorrections.id, imageS3Key: schema.aiCorrections.imageS3Key }) + .from(schema.aiCorrections) + .where(eq(schema.aiCorrections.userId, userId)); + + const bankRows = await app.db + .select({ imageS3Key: schema.aiTrainingBank.imageS3Key }) + .from(schema.aiTrainingBank) + .innerJoin( + schema.aiCorrections, + eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id), + ) + .where(eq(schema.aiCorrections.userId, userId)); + + const scanRows = await app.db + .select({ s3Keys: schema.scanJobs.s3Keys }) + .from(schema.scanJobs) + .where(eq(schema.scanJobs.userId, userId)); + + const imageKeys = new Set(); + for (const row of correctionRows) { + if (row.imageS3Key) imageKeys.add(row.imageS3Key); + } + for (const row of bankRows) { + if (row.imageS3Key) imageKeys.add(row.imageS3Key); + } + for (const row of scanRows) { + for (const key of row.s3Keys ?? []) { + if (key) imageKeys.add(key); + } + } + + for (const key of imageKeys) { + try { + await app.storage.deleteObject(key); + } catch (err) { + app.log.warn({ err, key, userId }, "Kunde inte radera bild vid GDPR-radering"); + } + } + + // ai_training_bank försvinner via correction_id ON DELETE CASCADE. + if (correctionRows.length > 0) { + await app.db + .delete(schema.aiCorrections) + .where( + inArray( + schema.aiCorrections.id, + correctionRows.map((r) => r.id), + ), + ); + } + + // scan_jobs har också ON DELETE CASCADE på users.id, men eftersom vi + // soft-deletar användaren måste vi radera explicit. + await app.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, userId)); + + // Hård radering av övrig persondata. await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, userId)); await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, userId)); await app.db.delete(schema.meals).where(eq(schema.meals.userId, userId)); diff --git a/apps/api/test/me.test.ts b/apps/api/test/me.test.ts new file mode 100644 index 0000000..2780241 --- /dev/null +++ b/apps/api/test/me.test.ts @@ -0,0 +1,216 @@ +import "./setup-env.js"; +import { describe, expect, it, beforeAll, afterAll, vi } from "vitest"; +import { eq, inArray } from "drizzle-orm"; +import { buildServer } from "../src/server.js"; +import { loadConfig } from "../src/config.js"; +import { createDatabase, closeDatabase, schema } from "@app/database"; + +describe("DELETE /v1/me — GDPR-radering", () => { + const testDb = createDatabase(process.env.TEST_DATABASE_URL!); + const config = loadConfig(); + let app: Awaited>; + + async function cleanupUser(email: string) { + const existing = await testDb.db + .select({ id: schema.users.id }) + .from(schema.users) + .where(inArray(schema.users.email, [email, `deleted-${email}`])); + for (const u of existing) { + const corrections = await testDb.db + .select({ id: schema.aiCorrections.id }) + .from(schema.aiCorrections) + .where(eq(schema.aiCorrections.userId, u.id)); + if (corrections.length > 0) { + await testDb.db + .delete(schema.aiTrainingBank) + .where( + inArray( + schema.aiTrainingBank.correctionId, + corrections.map((r) => r.id), + ), + ); + } + await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id)); + await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id)); + await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id)); + await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id)); + await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id)); + await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id)); + await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id)); + } + } + + async function registerUser(email: string) { + const res = await app.inject({ + method: "POST", + url: "/v1/auth/register", + payload: { email, password: "Password123!", displayName: "GDPR Delete Test" }, + }); + const body = JSON.parse(res.body) as { accessToken: string }; + const token = body.accessToken; + const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub; + + await app.inject({ + method: "POST", + url: "/v1/onboarding/quick-start", + headers: { authorization: `Bearer ${token}` }, + payload: { goals: ["less_waste"], precisionMode: "simple" }, + }); + + return { token, userId }; + } + + beforeAll(async () => { + app = await buildServer(config); + await app.ready(); + }); + + afterAll(async () => { + await closeDatabase(); + await app.close(); + }); + + it("raderar ai_corrections, ai_training_bank, scan_jobs och tillhörande S3-bilder", async () => { + const email = "gdpr-full-delete@example.invalid"; + await cleanupUser(email); + const { token, userId } = await registerUser(email); + + for (const kind of ["personalization", "anonymized_improvement", "image_training"] as const) { + await testDb.db + .insert(schema.userConsents) + .values({ userId, kind, status: "granted" as const }) + .onConflictDoUpdate({ + target: [schema.userConsents.userId, schema.userConsents.kind], + set: { status: "granted" as const }, + }); + } + + const [scanJob] = await testDb.db + .insert(schema.scanJobs) + .values({ + userId, + scanType: "fridge", + jobType: "ANALYZE_FRIDGE_IMAGE", + status: "awaiting_confirmation", + s3Keys: ["fridge-scans/scan-a.jpg", "fridge-scans/scan-b.jpg"], + }) + .returning(); + + const [correction] = await testDb.db + .insert(schema.aiCorrections) + .values({ + scanJobId: scanJob!.id, + userId, + taskType: "ANALYZE_FRIDGE_IMAGE", + aiOutput: { raw: {} }, + userCorrection: { action: "accept" }, + imageS3Key: "fridge-scans/correction-a.jpg", + consentSnapshot: { image_training: "granted", anonymized_improvement: "granted" }, + }) + .returning(); + + await testDb.db.insert(schema.aiTrainingBank).values({ + correctionId: correction!.id, + scanJobId: scanJob!.id, + taskType: "ANALYZE_FRIDGE_IMAGE", + imageS3Key: "fridge-scans/training-a.jpg", + proposal: { detectedName: "Mjölk" }, + action: "accept", + corrected: { displayName: "Mjölk" }, + consentSnapshot: { image_training: "granted", anonymized_improvement: "granted" }, + }); + + const deleteSpy = vi.spyOn(app.storage, "deleteObject").mockResolvedValue(undefined); + + const res = await app.inject({ + method: "DELETE", + url: "/v1/me", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.statusCode).toBe(200); + + const deletedKeys = deleteSpy.mock.calls.map((c) => c[0]).sort(); + expect(deletedKeys).toEqual( + [ + "fridge-scans/scan-a.jpg", + "fridge-scans/scan-b.jpg", + "fridge-scans/correction-a.jpg", + "fridge-scans/training-a.jpg", + ].sort(), + ); + + const remainingCorrections = await testDb.db + .select({ id: schema.aiCorrections.id }) + .from(schema.aiCorrections) + .where(eq(schema.aiCorrections.userId, userId)); + expect(remainingCorrections).toHaveLength(0); + + const remainingBank = await testDb.db + .select({ id: schema.aiTrainingBank.id }) + .from(schema.aiTrainingBank) + .innerJoin( + schema.aiCorrections, + eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id), + ) + .where(eq(schema.aiCorrections.userId, userId)); + expect(remainingBank).toHaveLength(0); + + const remainingScans = await testDb.db + .select({ id: schema.scanJobs.id }) + .from(schema.scanJobs) + .where(eq(schema.scanJobs.userId, userId)); + expect(remainingScans).toHaveLength(0); + + const [user] = await testDb.db + .select({ deletedAt: schema.users.deletedAt, email: schema.users.email }) + .from(schema.users) + .where(eq(schema.users.id, userId)) + .limit(1); + expect(user?.deletedAt).not.toBeNull(); + expect(user?.email).toContain("anonymized.invalid"); + + deleteSpy.mockRestore(); + await cleanupUser(email); + }); + + it("fortsätter raderingen även om lagringen kastar för en bild", async () => { + const email = "gdpr-storage-fail@example.invalid"; + await cleanupUser(email); + const { token, userId } = await registerUser(email); + + const [scanJob] = await testDb.db + .insert(schema.scanJobs) + .values({ + userId, + scanType: "pantry", + jobType: "ANALYZE_PANTRY_IMAGE", + status: "awaiting_confirmation", + s3Keys: ["pantry-scans/fail.jpg"], + }) + .returning(); + + const deleteSpy = vi + .spyOn(app.storage, "deleteObject") + .mockRejectedValueOnce(new Error("S3 nere")) + .mockResolvedValue(undefined); + + const res = await app.inject({ + method: "DELETE", + url: "/v1/me", + headers: { authorization: `Bearer ${token}` }, + }); + + expect(res.statusCode).toBe(200); + expect(deleteSpy).toHaveBeenCalledWith("pantry-scans/fail.jpg"); + + const remainingScans = await testDb.db + .select({ id: schema.scanJobs.id }) + .from(schema.scanJobs) + .where(eq(schema.scanJobs.userId, userId)); + expect(remainingScans).toHaveLength(0); + + deleteSpy.mockRestore(); + await cleanupUser(email); + }); +}); diff --git a/docs/28-lärande-loop.md b/docs/28-lärande-loop.md index b9e43da..8a8003d 100644 --- a/docs/28-lärande-loop.md +++ b/docs/28-lärande-loop.md @@ -96,23 +96,40 @@ användarens samtycke inte påverkar redan bankade data. användaren återkallar samtycke — då raderas endast rader där `consentSnapshot.anonymized_improvement = "denied"` (i praktiken exporteras de aldrig). -- Bilder i lagring: följer samma regler som `imageS3Key` — sparas så - länge kontot finns, raderas vid kontoradering. +- **Träningsbilder (`image_s3_key`)**: raderas senast **90 dagar efter + att de bankats** till `ai_training_bank`, om användaren inte aktivt + valt att spara sina bidrag längre. För användare som samtycker till + långtidslagring (t.ex. för att förbättra modellen över tid) kan + bilderna behållas så länge kontot finns, men aldrig längre än vad + användaren samtyckt till. Vid återkallat `image_training`-samtycke + raderas bilderna inom 30 dagar. +- Bilder som endast ingår i råa `scan_jobs` (inte bankade som + träningsdata): raderas enligt samma 90-dagarspolicy eller när skanningen + tas bort, beroende på vilket som inträffar först. ## GDPR / kontoradering -Vid kontoradering (eller rätten att bli glömd): +`DELETE /v1/me` soft-deletar (anonymiserar) `users`-raden för att +bevara referensintegritet i aggregat. Därför fyras **inte** FK-cascade +från `users` till `ai_corrections` eller `scan_jobs`. Istället sker +raderingen explicit: -- `users` → cascade delete → `ai_corrections` försvinner (FK `ON DELETE CASCADE`). -- `ai_corrections` → cascade delete → `ai_training_bank` försvinner (FK `ON DELETE CASCADE`). -- Bilder som refereras av `ai_corrections.imageS3Key` och - `ai_training_bank.imageS3Key` måste raderas från lagring. Detta görs av - en GDPR-raderingsprocessor (se Del 12) som läser bildnycklarna innan - användarposten tas bort. +1. Saml alla distinkta bildnycklar för användaren: + - `ai_corrections.image_s3_key` + - `ai_training_bank.image_s3_key` (via join mot `ai_corrections`) + - `scan_jobs.s3_keys` +2. Radera varje nyckel i objektlagring med `storage.deleteObject`. + S3-fel loggas och påverkar inte fortsatt radering. +3. Radera `ai_corrections` för `userId`. `ai_training_bank` försvinner + automatiskt via `correction_id ON DELETE CASCADE`. +4. Radera `scan_jobs` för `userId` (även denna cascade fyrar inte vid + soft-delete). +5. Fortsätt med hård radering av övrig persondata och anonymisera + användarposten. Verifiera alltid att kontoraderingstestet kontrollerar både -`ai_corrections`, `ai_training_bank` och att inga överblivna bildnycklar -finns kvar i S3/mock-lagringen. +`ai_corrections`, `ai_training_bank`, `scan_jobs` och att inga +överblivna bildnycklar finns kvar i S3/mock-lagringen. ## Inget PII i analytics