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));