fix(gdpr): explicit radering av ai_corrections/scan_jobs + S3-bilder vid DELETE /v1/me

- 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.
This commit is contained in:
Sven (AAMOS AI)
2026-08-08 04:20:03 +07:00
parent c2cc3878dd
commit 28908d5257
4 changed files with 327 additions and 15 deletions
+21 -1
View File
@@ -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<string>;
putObject(key: string, data: Buffer, contentType: string): Promise<void>;
getObject(key: string): Promise<Buffer | null>;
/** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */
deleteObject(key: string): Promise<void>;
}
declare module "fastify" {
@@ -91,6 +94,11 @@ class MockStorage implements StorageService {
}
}
async deleteObject(key: string): Promise<void> {
// 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<void> {
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) => {
+62 -3
View File
@@ -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<string>();
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));
+216
View File
@@ -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<ReturnType<typeof buildServer>>;
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);
});
});
+28 -11
View File
@@ -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