Files
Cibello-app/docs/29-gdpr-radering-audit.md
T
Sven (AAMOS AI) c32a7e33c7
CI / Typecheck, test & build (push) Failing after 2s
ci: trigga på master + formatfix inför Gitea Actions
2026-08-13 17:25:14 +07:00

40 KiB

GDPR-radering — kompletthetsaudit (cibello, HEAD c2cc38728908d5)

Syfte: identifiera varje tabell som bär user_id eller annan persondata, klassa den enligt GDPR, hitta luckor mot nuvarande DELETE /v1/me, och designa ett permanent residual-test.

Status: Implementerad i efterföljande commits. Residual-testet apps/api/test/me.residual.test.ts ingår permanent i pnpm test och failar automatiskt om nya user_id-liknande kolumner läggs till utan klassning.

Audit gjord mot:

  • packages/database/src/schema/*.ts
  • infrastructure/migrations/*.sql
  • apps/api/src/routes/me.ts (nuvarande DELETE /v1/me)
  • packages/database/src/analytics-gdpr.ts

1. Metod

  1. Enumrerade alla tabeller exporterade från packages/database/src/schema/index.ts.
  2. Grep:ade user_id, references(() => users.id) och fria text/PGD-fält som kan bära PII (foton, hälsa, kvitton, betyg, kommentarer).
  3. Klassade varje tabell i tre kategorier:
    • RADERA — direkt personlig, ingen laglig grund att behålla.
    • ANONYMISERA — behövs för referensintegritet/aggregat, men user_id och PII-fält ska skrubbas eller sättas NULL.
    • BEHÅLL MED LAGLIG GRUND — t.ex. bokföring, säkerhet, rättslig förpliktelse. Får inte raderas, men PII ska minimeras.
  4. Jämförde klassningen mot vad DELETE /v1/me faktiskt gör idag.

2. Tabell-för-tabell-klassning

2.1 Användarkonto & autentisering — RADERA

Tabell Motivering Nuvarande DELETE /v1/me
users Personliga grunduppgifter (e-post, namn). Soft-delete/anonymisering accepteras för att bevara referensintegritet, men övriga personfält måste skrubbas. anonymiserar email, displayName, sätter deletedAt.
user_credentials Lösenordshash. Ingen laglig grund efter radering. saknas — cascade på users.id fyrar inte vid soft-delete.
admin_totp TOTP-secret. Säkerhetsuppgift. saknas — cascade fyrar inte.
email_verification_tokens Engångstokens kopplade till användaren. saknas — cascade fyrar inte.
password_reset_tokens Engångstokens kopplade till användaren. saknas — cascade fyrar inte.
refresh_tokens Sessions-token-hash, IP, user-agent. raderas explicit.

Bedömning: Fyra auth-tabeller är ohanterade eftersom users soft-deletas. Måste explicit raderas.


2.2 Hälsa, preferenser, samtycken & locale — RADERA

Tabell Motivering Nuvarande DELETE /v1/me
user_health_profiles Hälsodata (längd, vikt, födelseår, kön, aktivitet). Känslig persondata. raderas explicit.
user_preferences Kost/mål/allergier/intoleranser. Personlig profilering. raderas explicit.
user_consents Samtyckeshistorik kopplad till person. saknas — cascade på users.id fyrar inte vid soft-delete.
user_locale_preferences Språk, region, tidszon, valuta. Personlig preferens. saknas — cascade fyrar inte.

Bedömning: user_consents och user_locale_preferences är nya luckor.


2.3 Hushåll & medlemskap — BESLUT KRÄVS

Tabell Motivering Nuvarande DELETE /v1/me
household_members Länkar användare till hushåll. Ingen laglig grund att behålla kopplingen efter radering. saknas helt.
households Om användaren är enda medlemmen/ägaren innehåller hushållet personliga spår (namn, budget, aktiveringsdatum, storlek). Om flera medlemmar finns är hushållet en delad resurs. saknas helt.
storage_locations Hushålls-specifika förvaringsplatser. Delad resurs om hushållet har flera medlemmar. saknas helt.

Bedömning: Detta är den största strukturella luckan. Förslag (se öppna frågor nedan):

  • Ta bort användarens household_members-rad(er).
  • Om användaren är ensam ägare/medlem i ett hushåll: radera hela hushållet och dess cascading data (storage_locations, inventory_items, inventory_transactions, shopping_lists, week_plans, meal_boxes, receipts, cooking_sessions, etc.).
  • Om andra medlemmar finns: låt hushållet leva, anonymisera användarreferenser i delade data.

Öppen fråga till Johan: Vill vi automatiskt radera ett enpersonshushåll vid kontoradering, eller ska användaren först tvingas överföra ägarskap? (GDPR-mässigt är automatisk radering av ett enpersonshushåll det säkraste.)


2.4 Lager, inköp, matplanering — hushållsdata (ANONYMISERA eller RADERA beroende på hushåll)

Tabell Klassning Motivering
inventory_items ANONYMISERA / RADERA Hushållsdata. Ingen personlig info i sig, men source, verifiedByUser etc. kan kopplas till användaren. Om hushållet överlever: sätt verifiedByUser = false där aktuell användare verifierat. Om hushållet raderas: cascade bort.
inventory_transactions ANONYMISERA actor_user_id är en personlig referens. Sätt NULL. Data behövs för hushållets saldo/historik.
inventory_item_decay_profile BEHÅLL / RADERA Teknisk koppling till inventory_items. Följer hushållets öde.
inventory_conflicts ANONYMISERA resolved_by_user_id ska vara NULL. Övriga fält är hushållsdata.
shopping_lists BEHÅLL Hushållsdata, ingen PII.
shopping_list_items ANONYMISERA added_by_user_id ska vara NULL.
receipts RADERA / ANONYMISERA Kvittobild (image_url) kan innehålla personuppgifter (butik, kortnummer, artiklar). Om hushållet överlever och kvittot är delat: radera bild-URL:en i objektlagring och anonymisera. Om hushållet raderas: cascade bort.
receipt_lines RADERA / ANONYMISERA raw_text är OCR-text från kvitto (potentiellt personligt). Samma logik som receipts.
price_observations BEHÅLL Aggregatdata, anonymiserad prisinformation. store_name är inte personligt i GDPR-mening.
meal_boxes ANONYMISERA reserved_for_user_id ska vara NULL.
meals RADERA Direkt personlig måltidslogg (user_id, photo_url). Raderas explicit idag.
week_plans BEHÅLL Hushållsdata, ingen PII.
week_plan_entries BEHÅLL Hushållsdata.
cooking_sessions ANONYMISERA started_by_user_id ska vara NULL. Sessionen är hushållshistorik.
cooking_assumption_profiles BEHÅLL / RADERA Hushållsaggregat. Följer hushållets öde.
waste_summaries BEHÅLL Hushållsaggregat, ingen PII.

Bedömning: Dessa tabeller är i princip ohanterade idag. De flesta är hushållsdata och ska anonymiseras snarare än raderas, förutsatt att hushållet överlever.


2.5 Skanningar, AI-korrigeringar & träningsbank — RADERA

Tabell Motivering Nuvarande DELETE /v1/me
scan_jobs Innehåller uppladdade bilder (s3_keys) och användarspecifika resultat. raderas explicit.
ai_corrections Innehåller personliga korrigeringar och bildnyckel. raderas explicit.
ai_training_bank Innehåller träningsbild och korrigeringsdata. Cascadar via ai_corrections. raderas via ai_corrections cascade.

Bedömning: Godkänd i föregående fix. Ingen ytterligare åtgärd.


2.6 Recept & matminnen — RADERA eller ANONYMISERA

Tabell Klassning Motivering
recipes ANONYMISERA Publik/verifierad receptdata är inte personlig, men creator_user_id och creator_display_name är personliga. Draft/private-recept däremot är personliga och bör raderas.
recipe_ratings RADERA Användarbetyg och kommentar är personligt.
recipe_favorites RADERA Användarens favoriter är personligt.
recipe_cooks RADERA / ANONYMISERA Logg över vilka recept användaren lagat. Personligt.
food_memories RADERA Matminne med eventuell bild (photo_url). Personligt.
memory_items RADERA AAMOS-minne — personligt.
taste_signals RADERA Smakpreferenser kopplade till användaren.
creator_stats RADERA Creator-profil, följare, statistik. Personligt.
creator_follows RADERA Följer/följare-relationer. Personliga.

Bedömning: Receptinteraktioner är i stort sett ohanterade.


2.7 Notiser & push — RADERA

Tabell Motivering Nuvarande DELETE /v1/me
notifications Push-/inapp-notiser till användaren. saknas (cascade fyrar inte).
push_tokens Push-token för Expo. Teknisk identifierare kopplad till användaren. saknas (cascade fyrar inte).

Bedömning: Båda är luckor.


2.8 Betalningar & prenumerationer — BEHÅLL MED LAGLIG GRUND, MEN ANONYMISERA

Tabell Klassning Motivering
subscriptions BEHÅLL Bokförings- och rättslig grund för att bevisa köp/åtkomst. user_id bör pseudonymiseras eller sättas NULL efter en viss period, men transaktionens existens måste kunna verifieras. Retention: minst 7 år (Bokföringslagen) eller tills rättslig tvist är avslutad.
subscription_events BEHÅLL Händelselogg för prenumeration. Samma grund. user_id kan anonymiseras.
store_transactions BEHÅLL Råa store-transaktioner för revision. raw_payload kan innehålla PII (store-kvitto) — ska skrubbas så att endast transaktions-ID, produkt-ID och belopp återstår. user_id anonymiseras. Retention: 7 år.
store_notifications BEHÅLL App/Play-servernotiser. Ingen direkt PII i sig, men kan innehålla transaktions-ID. Behålls för spårbarhet.
trials RADERA / BEHÅLL Trial-spårning. För rättvis kvot/fair-use kan vi behöva spara att denna person redan provat. Men efter kontoradering bör raden raderas eller pseudonymiseras (t.ex. hash av ursprungligt user_id för anti-fraud). Förslag: radera vid GDPR-radering; anti-fraud hanteras via store-transaktioner istället.
ai_usage_counters RADERA Månatlig AI-användning. Ingen laglig grund efter kontoradering.

Bedömning: Finansiella poster får inte raderas, men PII måste skalas bort. subscriptions, subscription_events, store_transactions behöver en anonymiseringsfunktion, inte deletion.


2.9 Analytics & audit — BEHÅLL MED LAGLIG GRUND, MEN ANONYMISERA

Tabell Klassning Motivering
product_analytics_events RADERA Pseudonyma events, men anonymous_id kan länka till användaren. Hard-delete görs redan via deleteUserAnalyticsEvents.
analytics_daily_snapshots BEHÅLL Hushållsaggregat, ingen PII.
audit_logs BEHÅLL Säkerhets-/rättslig grund. actor_user_id, ip och ev. PII i metadata ska anonymiseras (actor_user_idNULL, ipNULL, metadata skrubbas). Retention: minst 2 år, alternativt tills rättslig tvist är avslutad.
domain_events BEHÅLL / ANONYMISERA Event-sourcing outbox. user_id och PII i payload ska anonymiseras för att inte bryta downstream-konsumenter.
idempotency_keys RADERA Caching av API-svar kopplade till användaren. Ingen laglig grund.

2.10 Globala system- och översättningstabeller — BEHÅLL (ingen PII)

Tabell Klassning Motivering
canonical_ingredients BEHÅLL Global produktdata.
ingredient_translations BEHÅLL Översättningar av globala ingredienser.
unit_translations BEHÅLL Översättningar av enheter.
recipe_translations BEHÅLL Översättningar av recept.
recipe_step_translations BEHÅLL Översättningar av receptsteg.
recipe_source_registry BEHÅLL Juridisk källregister.
recipe_ingredients BEHÅLL Receptdata.
recipe_steps BEHÅLL Receptdata.
recipe_similarities BEHÅLL Systemdata.
substitutions BEHÅLL Systemdata.
nutrition_display_profiles BEHÅLL Systemdata.
allergen_market_rules BEHÅLL Systemdata.
season_events BEHÅLL Systemdata.
feature_flags BEHÅLL Systemdata.
release_gates BEHÅLL Systemdata.
inventory_decay_profiles BEHÄLL Systemdata.
ai_eval_cases BEHÅLL Testdata.
ai_eval_runs BEHÅLL Testkörningsresultat.

3. Gap-tabell mot nuvarande DELETE /v1/me

Nedan är tabeller som idag varken raderas, anonymiseras eller motiveras att behållas vid kontoradering.

# Tabell Kategori Risk Föreslagen åtgärd
1 user_credentials Auth Lösenordshash överlever radering. DELETE FROM user_credentials WHERE user_id = $1
2 admin_totp Auth TOTP-secret överlever. DELETE
3 email_verification_tokens Auth Tokens överlever. DELETE
4 password_reset_tokens Auth Tokens överlever. DELETE
5 user_consents Samtycken Samtyckeshistorik överlever. DELETE
6 user_locale_preferences Preferenser Locale-preferenser överlever. DELETE
7 household_members Hushåll Användaren kvarstår som medlem. DELETE
8 households (+ cascading data) Hushåll Enpersonshushåll läcker persondata. Beslut krävs (se §2.3)
9 inventory_transactions Hushåll actor_user_id kvarstår. SET actor_user_id = NULL
10 inventory_conflicts Hushåll resolved_by_user_id kvarstår. SET resolved_by_user_id = NULL
11 shopping_list_items Hushåll added_by_user_id kvarstår. SET added_by_user_id = NULL
12 meal_boxes Hushåll reserved_for_user_id kvarstår. SET reserved_for_user_id = NULL
13 cooking_sessions Hushåll started_by_user_id kvarstår. SET started_by_user_id = NULL
14 receipts Hushåll Kvittobild (image_url) kan innehålla PII. Radera bild i lagring, sätt image_url = NULL
15 receipt_lines Hushåll OCR-text kan innehålla PII. Skrubba raw_text eller radera rader om hushåll raderas.
16 recipe_ratings Recept Betyg/kommentar överlever. DELETE
17 recipe_favorites Recept Favoriter överlever. DELETE
18 recipe_cooks Recept Lagningslogg överlever. DELETE
19 food_memories Matminnen Matminnen + bild överlever. DELETE (+ bild i lagring)
20 creator_stats Creator Creator-profil överlever. DELETE
21 creator_follows Creator Följer/följare överlever. DELETE
22 notifications Notiser Notiser överlever. DELETE
23 push_tokens Push Push-token överlever. DELETE
24 trials Trial Trial-info överlever. DELETE
25 ai_usage_counters Usage AI-användning överlever. DELETE
26 audit_logs Audit actor_user_id, ip, metadata överlever. Anonymisera (NULL + skrubba metadata)
27 domain_events Events user_id + PII i payload överlever. Anonymisera (NULL + skrubba payload)
28 idempotency_keys API Cachade svar kopplade till användaren överlever. DELETE
29 subscriptions Betalning user_id kvarstår för evigt. Pseudonymisera efter retention (eller direkt vid radering om ekonomiskt spår finns kvar i store_transactions)
30 subscription_events Betalning user_id + payload kvarstår. Anonymisera (user_id = NULL, skrubba payload)
31 store_transactions Betalning raw_payload kan innehålla PII. Skrubba raw_payload, anonymisera user_id

Totalt: 31 tabell-förekomster som behöver åtgärdas (många är SET NULL, inte radering).


4. Bilder / objektlagring

Förutom redan hanterade scan_jobs.s3_keys, ai_corrections.image_s3_key och ai_training_bank.image_s3_key finns följande användarbilder:

Källa Kolumn Risk Åtgärd vid GDPR-radering
meals.photo_url Foto på måltid Personlig bild. Radera objektet i lagring; sätt photo_url = NULL.
food_memories.photo_url Foto från matminne Personlig bild. Radera objektet i lagring; sätt photo_url = NULL.
receipts.image_url Kvittobild Potentiellt personlig (kortnummer, butik, köp). Radera objektet i lagring; sätt image_url = NULL.
recipes.image_urls Receptbild Om receptet är draft/private och skapats av användaren: personlig. Om publikt/verifierat: delad resurs. För draft/private: radera bilderna. För publika: behåll (om användaren samtyckt) eller anonymisera skaparen.

Notering: Det finns ingen dedikerad profilbildskolumn i users. Om sådan läggs till i framtiden måste den inkluderas i residual-testet.


5. Design — RESIDUAL-TEST

Mål: en permanent test som skapar en användare med en rad i varje personlig tabell, kör DELETE /v1/me, och verifierar att inga personspår kvarstår (utom motiverat-behåll som måste vara PII-fritt/anonymiserat).

5.1 Teststruktur (förslag)

describe("DELETE /v1/me — GDPR residual test", () => {
  it("lämnar inga personspår efter kontoradering", async () => {
    const { token, userId } = await registerUser("residual@example.invalid");

    // 1. Skapa en rad i varje personlig tabell
    await seedAllPersonalTables(userId, token);

    // 2. Kör DELETE /v1/me
    const res = await app.inject({
      method: "DELETE",
      url: "/v1/me",
      headers: { authorization: `Bearer ${token}` },
    });
    expect(res.statusCode).toBe(200);

    // 3. Verifiera: varje tabell är antingen tom eller anonym/PII-fri
    await assertNoPersonalTraces(app.db, userId);
  });
});

5.2 Hjälpfunktioner

seedAllPersonalTables(userId, token) ska skapa exempeldata i:

  • user_credentials (skapas vid registrering)
  • user_health_profiles, user_preferences, user_locale_preferences, user_consents
  • households + household_members (+ storage_locations)
  • scan_jobs + ai_corrections + ai_training_bank
  • meals, memory_items, taste_signals, food_memories
  • recipe_ratings, recipe_favorites, recipe_cooks, creator_stats, creator_follows
  • notifications, push_tokens
  • trials, ai_usage_counters
  • audit_logs, domain_events, idempotency_keys
  • subscriptions, subscription_events, store_transactions
  • inventory_transactions, inventory_conflicts, shopping_list_items, meal_boxes, cooking_sessions
  • receipts + receipt_lines

assertNoPersonalTraces(db, userId) ska:

  1. För RADERA-tabeller: SELECT count(*) = 0 WHERE user_id = $userId.
  2. För ANONYMISERA-tabeller: SELECT count(*) = 0 WHERE user_id = $userId (eftersom user_id ska vara NULL).
  3. För BEHÅLL-tabeller: tillåta rader med user_id = $userId men kontrollera att känsliga fält (raw_payload, metadata, ip) är skrubbade.
  4. För bilder: mocka storage.deleteObject och verifiera att alla personliga bildnycklar raderats.
  5. För users: kontrollera att email, displayName är anonymiserade och deletedAt är satt.

5.3 Mekanism för att upptäcka nya tabeller

För att residual-testet automatiskt ska upptäcka framtida tabeller som lägger till user_id:

// I testet: reflektera schemat och jämför med en allow-list
const userIdColumns = await db.execute(sql`
  SELECT table_name, column_name
  FROM information_schema.columns
  WHERE table_schema = 'public'
    AND column_name IN ('user_id', 'actor_user_id', 'creator_user_id',
                        'started_by_user_id', 'resolved_by_user_id',
                        'added_by_user_id', 'reserved_for_user_id')
`);
// Jämför med allow-list över tabeller som testet täcker.
// Om en ny tabell dyker upp: testet failar tills den klassas.

Denna "schema-diff"-assertion gör testet till en permanent vakt.

5.4 Förväntat testresultat idag (före implementation)

Testet kommer att faila på de ~31 luckor listade i §3. Det är avsiktligt — det blir den röda tråden som implementationen ska göra grön.


6. Öppna beslut innan implementation

Följande punkter behöver ditt (Johans) godkännande eller vägledning:

  1. Enpersonshushåll vid radering: Ska vi automatiskt radera hela hushållet (inkl. lager, listor, kvitton, matplaner) när enda medlemmen begär GDPR-radering? Alternativet är att tvinga användaren överföra ägarskap först, vilket är mer komplext.

    • Rekommendation: Automatisk radering av enpersonshushåll.
  2. Publika recept: Ska ett publikt/verifierat recept som användaren skapat anonymiseras (creator_user_id = NULL, creator_display_name = NULL) eller raderas helt?

    • Rekommendation: Anonymisera publika recept, radera draft/private.
  3. Finansiella poster: Ska subscriptions.user_id pseudonymiseras direkt vid GDPR-radering, eller först efter 7 år? Bokföringslagen kräver att vi kan bevisa köpet, men inte nödvändigtvis vem som gjorde det.

    • Rekommendation: Sätt subscriptions.user_id = NULL direkt vid radering; ekonomisk spårbarhet finns kvar i store_transactions (som skrubbas men behåller transaktions-ID).
  4. Audit logs: Ska audit_logs behållas med actor_user_id = NULL i 2+ år, eller raderas efter kortare tid?

    • Rekommendation: Behåll 2 år med anonymiserad aktör, för säkerhetsincident-utredning.
  5. Domain events: Ska domain_events behållas för event-sourcing, men med user_id = NULL och skrubbad payload?

    • Rekommendation: Ja, behåll för downstream replay/konsumenter.

7. Sammanfattning & rekommenderad ordningsföljd

7.1 Snabbfix (högsta riskerna)

  1. Radera auth-tabeller: user_credentials, admin_totp, email_verification_tokens, password_reset_tokens.
  2. Radera preferenser/samtycken: user_consents, user_locale_preferences.
  3. Radera notiser/push: notifications, push_tokens.
  4. Radera trials/usage: trials, ai_usage_counters.
  5. Radera idempotency: idempotency_keys.
  6. Radera receptinteraktioner: recipe_ratings, recipe_favorites, recipe_cooks, creator_stats, creator_follows, food_memories.
  7. Anonymisera hushållsreferenser: inventory_transactions.actor_user_id, inventory_conflicts.resolved_by_user_id, shopping_list_items.added_by_user_id, meal_boxes.reserved_for_user_id, cooking_sessions.started_by_user_id.
  8. Hantera kvitton: radera receipts.image_url i lagring, skrubba receipt_lines.raw_text.

7.2 Större beslut

  1. Hushållsstrategi: radera enpersonshushåll, anonymisera delade hushåll.
  2. Finansiella poster: anonymisera subscriptions/subscription_events/store_transactions enligt retention.
  3. Audit/domain events: anonymisera men behåll enligt retention.

7.3 Permanent vakt

  1. Implementera residual-testet i apps/api/test/me.residual.test.ts med schema-diff-assertion.

8. Bilaga — fullständig tabellista från schema

(Används för att säkerställa att inga tabeller missats.)

adminTotp, aiCorrections, aiEvalCases, aiEvalRuns, aiTrainingBank,
aiUsageCounters, allergenMarketRules, analyticsDailySnapshots, auditLogs,
canonicalIngredients, cookingAssumptionProfiles, cookingSessions,
creatorFollows, creatorStats, domainEvents, emailVerificationTokens,
featureFlags, foodMemories, householdMembers, households, idempotencyKeys,
ingredientTranslations, inventoryConflicts, inventoryDecayProfiles,
inventoryItemDecayProfile, inventoryItems, inventoryTransactions,
mealBoxes, meals, memoryItems, notifications, nutritionDisplayProfiles,
passwordResetTokens, priceObservations, productAnalyticsEvents, products,
pushTokens, receiptLines, receipts, recipeCooks, recipeFavorites,
recipeIngredients, recipeRatings, recipeSimilarities, recipeSourceRegistry,
recipeStepTranslations, recipeSteps, recipeTranslations, recipes,
refreshTokens, releaseGates, scanJobs, seasonEvents, shoppingListItems,
shoppingLists, storageLocations, storeNotifications, storeTransactions,
subscriptionEvents, subscriptions, substitutions, tasteSignals, trials,
unitTranslations, userConsents, userCredentials, userHealthProfiles,
userLocalePreferences, userPreferences, users, wasteSummaries,
weekPlanEntries, weekPlans

Alla tabeller är granskade och klassade ovan.