Tillbaka till bloggen
8 min läsning

GDPR och datamigrering: det du inte bara kan kopiera över

Isak La Fleur EngdahlAv Isak La Fleur Engdahl
Säkra testdatamiljön: riktiga personuppgifter rinner bara till driftsättningen i produktion; varje test- och stagingmiljö körs på pseudonymiserade ersättningar.

En migrering är ett av få tillfällen då hela en organisations datalandskap är i rörelse på samma gång – extraheras, transformeras, laddas, valideras, körs om, och körs om igen. Och varje steg sker först i en icke-produktionsmiljö: en staging-yta, en testinstans, en konsults laptop, en CSV-fil i en projektmapp. Det är precis så man ska arbeta. Det är också precis där personuppgifter brukar läcka ut ur sin lagliga hemvist.

Jag hjälpte nyligen till att migrera ett medlemsregister med fler än 2,5 miljoner svenskar registrerade – ungefär så känslig källa som svensk data blir. Varje medlem bar ett personnummer, fullständigt namn, adress, c/o, e-post, mobilnummer, födelsedatum och en markering för skyddad identitet. I den skalan är en enda slarvig export en incident som drabbar miljontals människor. Sådan data testladdar man ett dussin gånger före driftsättning – och det enda man inte får göra är att köra de testerna på de riktiga personuppgifterna.

Principen: testmiljöer behöver inga riktiga människor

GDPR säger inte "migrera inte personuppgifter" – självklart migrerar du dem; det är ju hela poängen med projektet, och det vilar på en laglig grund. Vad regelverket säger är uppgiftsminimering (artikel 5.1 c) och ändamålsbegränsning: du behandlar bara de personuppgifter du faktiskt behöver, för det ändamål du samlat in dem för. Att testa ett transformationsskript är inte det ändamålet. Att bevisa att 80 000 rader mappas korrekt från det gamla schemat till det nya kräver inte medlemmarnas riktiga personnummer – det kräver 80 000 rader som beter sig som de riktiga.

Så regeln jag jobbar efter är enkel: den enda miljö som någonsin ser de riktiga personuppgifterna är själva driftsättningen i produktion. Varje testladdning, varje utvecklingsiteration, varje skärmdump i en statusrapport körs på ersättningsdata. Den enda gränsen tar bort en hel klass av risk – en läckt testexport, en alltför generöst delad CSV, en backup av en testdatabas – eftersom det inte finns något känsligt på de ställena att läcka.

Pseudonymisering, inte bara "förvanskning"

Här kommer nyansen som spelar roll för verksamheten, inte bara för ingenjören. GDPR drar en gräns mellan två saker:

  • Anonymisering – datan kan inte längre kopplas till en person av någon, med medel som rimligen kan komma att användas. Verkligt anonym data faller utanför GDPR.
  • Pseudonymisering (artikel 4.5) – datan kan inte hänföras till en person utan kompletterande uppgifter som du förvarar separat och skyddar. Pseudonymiserad data är fortfarande personuppgifter, men det är en erkänd skyddsåtgärd som påtagligt sänker risken.

Det jag byggde för medlemsprojektet är pseudonymisering, och det är ärligt att kalla det så. Ersättningarna är trovärdiga och konsekventa, men metoden är deterministisk och nyckelbaserad – så tekniken skyddar dig bara om nyckeln skyddas. Jag återkommer till det, för det är den delen folk gör fel.

Varför fejkdatan måste vara deterministisk

Det naiva angreppssättet är att generera en slumpmässig fejk för varje fält och gå vidare. Det förstör migreringstesterna, eftersom en riktig datamängd har en referensintegritet du behöver bevara:

  • Samma medlem dyker upp i flera filer och flera tabeller – ordrar, betalningar, utskick. Om "medlem 4711" blir Anna i en fil och Johan i en annan rasar dina joins och du kan inte testa relationerna.
  • Ett personnummer kodar in födelsedatumet. Byt det mot ett slumptal och den härledda födelsedatumkolumnen stämmer inte längre överens – en validering som hade gått igenom i produktion fallerar nu i test av fel skäl.
  • Datan måste se riktig ut så att transformationslogiken verkligen prövas: ett svenskt mobilnummer måste fortfarande börja på +46 och ha rätt längd; en e-post behöver fortfarande en trovärdig domän; en gatuadress behöver fortfarande ett husnummer.

Svaret är deterministisk pseudonymisering: samma indata ger alltid samma fejk, och en given medlem mappas till en och samma stabila fejkidentitet överallt. Det får jag genom att seeda generatorn med en nycklad hash av ursprungsvärdet – HMAC-SHA256 med en hemlighet – i stället för en slumpmässig seed. Samma indata + samma hemlighet → samma seed → samma fejk. Annan hemlighet → en helt annan mappning. Hemligheten är de "kompletterande uppgifter" GDPR talar om; håll den åtskild från datan så håller pseudonymiseringen.

import hashlib, hmac, os, random
from datetime import date
from faker import Faker

# Hemligheten är nyckeln till hela systemet. Ladda den från miljövariabel
# eller en secrets manager – ALDRIG hårdkoda, ALDRIG checka in i git.
SECRET = os.environ["ANON_SECRET"]

fake = Faker("sv_SE")  # locale spelar roll: svenska namn, gator, format

def _seed_from_value(value: str, secret: str = SECRET) -> int:
    """Deterministisk seed: samma indata + samma hemlighet -> samma fejk, överallt."""
    digest = hmac.new(secret.encode(), str(value).encode("utf-8"),
                      hashlib.sha256).digest()
    return int.from_bytes(digest[:8], "big")

def pseudo_first_name(x):
    if _nullish(x):
        return x
    Faker.seed(_seed_from_value(x))
    return fake.first_name()

def pseudo_last_name(x):
    if _nullish(x):
        return x
    Faker.seed(_seed_from_value(x))
    return fake.last_name()

Faker("sv_SE") gör ett tyst men viktigt jobb: ersättningarna är svenska namn, gator och format, så testdatan blir representativ för den riktiga populationen – inte angliserat brus som beter sig annorlunda.

Formatbevarande per fält

Generisk fejkdata räcker inte – varje fält har regler som det mottagande systemet upprätthåller, så fejkarna måste respektera dem. Några av fälthanterarna från projektet:

Ett giltigt personnummer, inte bara tolv slumpsiffror. Ett svenskt personnummer har en Luhn-kontrollsiffra; ladda ett som inte stämmer och målsystemet avvisar raden av ett skäl som inte har något med din mappning att göra. Så fejken genereras till att vara strukturellt giltig – ett trovärdigt födelsedatum, sedan en korrekt kontrollsiffra:

def _luhn_checksum_10(d9: str) -> int:
    s = 0
    for i, ch in enumerate(d9):
        d = (ord(ch) - 48) * (2 if i % 2 == 0 else 1)
        s += d // 10 + d % 10
    return (10 - (s % 10)) % 10

def pseudo_personnummer(x: str) -> str:
    if _nullish(x):
        return x
    rnd = random.Random(_seed_from_value(x))   # deterministisk, nycklad
    today = date.today()
    year  = rnd.randint(today.year - 99, today.year)
    month = rnd.randint(1, 12)
    day   = rnd.randint(1, 28)                  # säkert mot ogiltiga datum
    indiv = f"{rnd.randint(1, 999):03d}"
    nine  = f"{str(year)[2:]}{month:02d}{day:02d}{indiv}"
    return f"{year:04d}{month:02d}{day:02d}{indiv}{_luhn_checksum_10(nine)}"

Behåll e-postdomänen, byt personen. Domänmixen (företagsdomän kontra gratis webbmail) är ofta en del av det du testar, så det realistiska greppet är att fejka den lokala delen och behålla domänen:

def pseudo_email(x: str) -> str:
    if _nullish(x):
        return x
    local, domain = x.split("@", 1)
    Faker.seed(_seed_from_value(local))
    return f"{fake.user_name()}@{domain}"

Bevara formen på ett telefonnummer. Behåll +46, behåll mellanslagen och bindestrecken källan använde, och förvanska bara siffrorna – så att längdkontroller och formatvalideringar fortsätter pröva samma vägar.

Behåll husnumret, byt gatunamnet. En adressvalidering bryr sig om strukturen; en regex plockar ut gatunamnet och byter bara det, medan numret och eventuell extra del lämnas orörda.

Och en domännyans som ren verktygslogik skulle missa: medlemmar som är markerade med skyddad identitet skrivs över rakt av med den bokstavliga texten Personuppgift skyddad, inte med en snygg fejk. Det här är människor som har ett verkligt skäl till att deras uppgifter är skyddade; testdatan ska göra den statusen tydlig och synlig, aldrig dölja den bakom en trovärdig adress.

protected = df["ProtectedIdentity"].str.strip() == "1"
df.loc[protected,
       ["GivenName", "LastName", "Street", "ZipCode", "City", "CareOf"]
      ] = "Personuppgift skyddad"

Orkestreringen är bara en kolumn-för-kolumn-mappning, sedan ett par härledda fält (födelsedatumkolumnen räknas om från det nya personnumret så att de hålls konsekventa):

def anonymize(df):
    df["SocialSecurityNumber"] = df["SocialSecurityNumber"].map(pseudo_personnummer)
    df["GivenName"]    = df["GivenName"].map(pseudo_first_name)
    df["LastName"]     = df["LastName"].map(pseudo_last_name)
    df["CareOf"]       = df["CareOf"].map(pseudo_co)
    df["Street"]       = df["Street"].map(pseudo_street)
    df["EmailAddress"] = df["EmailAddress"].map(pseudo_email)
    df["MobileNumber"] = df["MobileNumber"].map(pseudo_mobile)
    # Härled födelsedatum från det *pseudonymiserade* personnumret så de stämmer.
    df["BirthDate"] = df["SocialSecurityNumber"].map(birth_date_from_ssn)
    return df

Var det här tar slut – de ärliga förbehållen

En teknik som den här förtjänar förtroende bara om du är tydlig med dess gränser. Pseudonymisering är en skyddsåtgärd, inte ett trollsudd, och att behandla den som ett trollsudd är hur organisationer åker dit.

  • Pseudonymiserat är fortfarande personuppgifter. Eftersom mappningen är deterministisk och nyckelbaserad kan vem som helst med hemligheten och ursprungsvärdena återskapa kopplingen. Resultatet är alltså personuppgifter med lägre risk, inte anonym data – hantera det därefter, och påstå inte att det "faller utanför GDPR".
  • Hemligheten är kronjuvelerna. Hela skyddet vilar på att nyckeln hålls åtskild från datan. Ladda den från en secrets manager eller miljövariabel, hårdkoda den aldrig, checka aldrig in den i git, och skicka den aldrig tillsammans med den anonymiserade exporten. (Det är precis därför kodsnutten ovan läser os.environ["ANON_SECRET"].)
  • Ett personnummer är ett litet, gissningsbart rum. Deterministisk pseudonymisering av ett fält med låg entropi kan i princip forceras om både hemligheten och algoritmen läcker. Försvaret är att skydda hemligheten – inte att anta att resultatet är obrytbart.
  • Spar inte mappningen. Behöver du inte återidentifiera, lagra då ingen uppslagstabell alls – den tabellen är återidentifieringsrisken samlad i en fil.
  • Driftsättningen är något annat. Den riktiga laddningen körs på den riktiga datan, på projektets lagliga grund, med den åtkomstkontroll och spårbarhet det innebär. Anonymiserad testdata är till för att bygga och bevisa migreringen; den är inte migreringen.
  • Skriv ner det. Vilka fält som pseudonymiseras, hur och varför hör hemma i projektets registerförteckning. "Vi fejkade testdatan" är en kontroll du ska kunna belägga, inte en tjänst du gjorde i det tysta.

Vad verksamheten ska ta med sig

Rubriken är inte Python-koden – det är gränsen. Bestäm, i början av en migrering, att riktiga personuppgifter bor på exakt ett ställe: driftsättningen i produktion. Allt uppströms från det körs på data som ser ut och beter sig som det riktiga men inte kan skada någon om den läcker. Det kostar en dags arbete att sätta upp, det gör testmiljöerna dramatiskt säkrare, och det låter hela teamet – utvecklare, testare, de som granskar skärmdumpar på ett statusmöte – arbeta utan att någonsin röra en riktig medlems personnummer.

Det är uppgiftsminimering i praktiken, och på en känslig datamängd är det skillnaden mellan en migrering som är omsorgsfull och en som bara har tur.

Står du inför att migrera en känslig datamängd och vill ha testmiljöer som inte håller dig vaken om nätterna? Hör av dig – jag delar gärna med mig av hur jag arbetar.


Den här artikeln beskriver ett generellt arbetssätt, inte juridisk rådgivning. Referenser: GDPR artikel 4.5 (pseudonymisering) och artikel 5 (uppgiftsminimering, ändamålsbegränsning); Python-biblioteket Faker.