Innan, under och efter att GDPR kom till stan 2018, har det funnits många idéer för att lösa problemet med att ta bort eller dölja användardata, genom att använda olika lager i mjukvarustacken men också använda olika tillvägagångssätt (hård radering, mjuk radering, anonymisering). Anonymisering har varit en av dem som är känd för att vara populär bland PostgreSQL-baserade organisationer/företag.
I GDPR:s anda ser vi mer och mer kravet på affärsdokument och rapporter som utbyts mellan företag, så att de individer som visas i dessa rapporter presenteras anonymiserade, det vill säga att endast deras roll/titel visas , medan deras personuppgifter är dolda. Detta händer antagligen på grund av det faktum att de företag som tar emot dessa rapporter inte vill hantera dessa data enligt procedurerna/processerna i GDPR, de vill inte ta itu med bördan av att utforma nya procedurer/processer/system för att hantera dem , och de ber bara att få uppgifterna som redan är föranonymiserade. Så denna anonymisering gäller inte bara de individer som har uttryckt sin önskan att bli bortglömda, utan faktiskt alla personer som nämns i rapporten, vilket skiljer sig ganska mycket från den vanliga GDPR-praxisen.
I den här artikeln kommer vi att ta itu med anonymisering för en lösning på detta problem. Vi börjar med att presentera en permanent lösning, det vill säga en lösning där en person som begär att bli glömd ska döljas i alla framtida förfrågningar i systemet. Utöver detta kommer vi att presentera ett sätt att uppnå "on demand", dvs kortlivad anonymisering, vilket innebär implementering av en anonymiseringsmekanism avsedd att vara aktiv precis tillräckligt länge tills de nödvändiga rapporterna genereras i systemet. I lösningen jag presenterar kommer detta att ha en global effekt, så den här lösningen använder ett girigt tillvägagångssätt, som täcker alla applikationer, med minimal (om någon) kodomskrivning (och kommer från tendensen hos PostgreSQL DBA:er att lösa sådana problem centralt och lämna appen utvecklare hanterar sin verkliga arbetsbörda). Metoderna som presenteras här kan dock enkelt anpassas för att användas i begränsade/snävare omfattningar.
Permanent anonymisering
Här kommer vi att presentera ett sätt att uppnå anonymisering. Låt oss överväga följande tabell som innehåller uppgifter om ett företags anställda:
testdb=# create table person(id serial primary key, surname text not null, givenname text not null, midname text, address text not null, email text not null, role text not null, rank text not null);
CREATE TABLE
testdb=# insert into person(surname,givenname,address,email,role,rank) values('Singh','Kumar','2 some street, Mumbai, India','[email protected]','Seafarer','Captain');
INSERT 0 1
testdb=# insert into person(surname,givenname,address,email,role,rank) values('Mantzios','Achilleas','Agiou Titou 10, Iraklio, Crete, Greece','[email protected]','IT','DBA');
INSERT 0 1
testdb=# insert into person(surname,givenname,address,email,role,rank) values('Emanuel','Tsatsadakis','Knossou 300, Iraklio, Crete, Greece','[email protected]','IT','Developer');
INSERT 0 1
testdb=#
Denna tabell är offentlig, alla kan fråga efter den och tillhör det offentliga schemat. Nu skapar vi den grundläggande mekanismen för anonymisering som består av:
- ett nytt schema för relaterade tabeller och vyer, låt oss kalla detta anonymt
- en tabell som innehåller id:n för personer som vill bli glömda:anonym.person_anonym
- en vy som tillhandahåller den anonymiserade versionen av public.person:anonym.person
- inställning av sökvägen för att använda den nya vyn
testdb=# create schema anonym;
CREATE SCHEMA
testdb=# create table anonym.person_anonym(id INT NOT NULL REFERENCES public.person(id));
CREATE TABLE
CREATE OR REPLACE VIEW anonym.person AS
SELECT p.id,
CASE
WHEN pa.id IS NULL THEN p.givenname
ELSE '****'::character varying
END AS givenname,
CASE
WHEN pa.id IS NULL THEN p.midname
ELSE '****'::character varying
END AS midname,
CASE
WHEN pa.id IS NULL THEN p.surname
ELSE '****'::character varying
END AS surname,
CASE
WHEN pa.id IS NULL THEN p.address
ELSE '****'::text
END AS address,
CASE
WHEN pa.id IS NULL THEN p.email
ELSE '****'::character varying
END AS email,
role,
rank
FROM person p
LEFT JOIN anonym.person_anonym pa ON p.id = pa.id
;
Låt oss ställa in sökvägen till vår applikation:
set search_path = anonym,"$user", public;
Varning :det är viktigt att sökvägen är korrekt inställd i datakällans definition i applikationen. Läsaren uppmuntras att utforska mer avancerade sätt att hantera sökvägen, t.ex. med användning av en funktion som kan hantera mer komplex och dynamisk logik. Du kan till exempel ange en uppsättning datainmatningsanvändare (eller roll) och låta dem fortsätta använda tabellen public.person under hela anonymiseringsintervallet (så att de kommer att fortsätta se normal data), samtidigt som de definierar en lednings-/rapporteringsuppsättning användare (eller roll) för vilken anonymiseringslogiken kommer att gälla.
Låt oss nu fråga vår personrelation:
testdb=# select * from person;
-[ RECORD 1 ]-------------------------------------
id | 2
givenname | Achilleas
midname |
surname | Mantzios
address | Agiou Titou 10, Iraklio, Crete, Greece
email | [email protected]
role | IT
rank | DBA
-[ RECORD 2 ]-------------------------------------
id | 1
givenname | Kumar
midname |
surname | Singh
address | 2 some street, Mumbai, India
email | [email protected]
role | Seafarer
rank | Captain
-[ RECORD 3 ]-------------------------------------
id | 3
givenname | Tsatsadakis
midname |
surname | Emanuel
address | Knossou 300, Iraklio, Crete, Greece
email | [email protected]
role | IT
rank | Developer
testdb=#
Låt oss nu anta att Mr Singh lämnar företaget och uttryckligen uttrycker sin rätt att bli bortglömd genom ett skriftligt uttalande. Applikationen gör detta genom att infoga hans id i uppsättningen av "att glömmas bort" id:
testdb=# insert into anonym.person_anonym (id) VALUES(1);
INSERT 0 1
Låt oss nu upprepa den exakta frågan vi körde tidigare:
testdb=# select * from person;
-[ RECORD 1 ]-------------------------------------
id | 1
givenname | ****
midname | ****
surname | ****
address | ****
email | ****
role | Seafarer
rank | Captain
-[ RECORD 2 ]-------------------------------------
id | 2
givenname | Achilleas
midname |
surname | Mantzios
address | Agiou Titou 10, Iraklio, Crete, Greece
email | [email protected]
role | IT
rank | DBA
-[ RECORD 3 ]-------------------------------------
id | 3
givenname | Tsatsadakis
midname |
surname | Emanuel
address | Knossou 300, Iraklio, Crete, Greece
email | [email protected]
role | IT
rank | Developer
testdb=#
Vi kan se att Mr Singhs uppgifter inte är tillgängliga från applikationen.
Tillfällig global anonymisering
Huvudidén
- Användaren markerar början av anonymiseringsintervallet (en kort tidsperiod).
- Under det här intervallet är endast markeringar tillåtna för den tabell som namnges.
- All åtkomst (selects) anonymiseras för alla poster i persontabellen, oavsett tidigare anonymiseringsinställningar.
- Användaren markerar slutet på anonymiseringsintervallet.
Byggstenar
- Tvåfas commit (alias förberedda transaktioner).
- Explicit tabelllåsning.
- Anonymiseringskonfigurationen vi gjorde ovan i avsnittet "Permanent anonymisering".
Implementering
En speciell admin-app (t.ex. kallad :markStartOfAnynimizationPeriod) utför
testdb=# BEGIN ;
BEGIN
testdb=# LOCK public.person IN SHARE MODE ;
LOCK TABLE
testdb=# PREPARE TRANSACTION 'personlock';
PREPARE TRANSACTION
testdb=#
Vad ovanstående gör är att skaffa ett lås på bordet i DELA-läge så att INFOGA, UPPDATERINGAR, RADERAR blockeras. Också genom att starta en tvåfas commit-transaktion (AKA preparerad transaktion, i andra sammanhang känd som distribuerade transaktioner eller eXtended Architecture-transaktioner XA) frigör vi transaktionen från anslutningen till sessionen som markerar början av anonymiseringsperioden, samtidigt som vi låter andra efterföljande sessioner vara medveten om dess existens. Den förberedda transaktionen är en ihållande transaktion som förblir vid liv efter att anslutningen/sessionen som har startat den har kopplats bort (via PREPARE TRANSACTION). Observera att "PREPARE TRANSACTION"-satsen tar bort transaktionen från den aktuella sessionen. Den förberedda transaktionen kan hämtas vid en efterföljande session och antingen återställas eller committeras. Användningen av denna typ av XA-transaktioner gör det möjligt för ett system att på ett tillförlitligt sätt hantera många olika XA-datakällor och utföra transaktionslogik över dessa (möjligen heterogena) datakällor. Men skälen till att vi använder det i det här specifika fallet:
- för att möjliggöra för den utfärdande klientsessionen att avsluta sessionen och koppla ur/frigöra dess anslutning (att lämna eller ännu värre "bestå" en anslutning är en riktigt dålig idé, en anslutning bör frigöras så snart den utförs de frågor den behöver göra)
- för att göra efterföljande sessioner/anslutningar som kan fråga efter existensen av denna förberedda transaktion
- för att göra den avslutande sessionen kapabel att utföra denna förberedda transaktion (genom att använda dess namn) och markera :
- släppet av DELNINGSLÄGE-låset
- slutet på anonymiseringsperioden
För att verifiera att transaktionen är levande och associerad med SHARE-låset på vårt personbord gör vi:
testdb=# select px.*,l0.* from pg_prepared_xacts px , pg_locks l0 where px.gid='personlock' AND l0.virtualtransaction='-1/'||px.transaction AND l0.relation='public.person'::regclass AND l0.mode='ShareLock';
-[ RECORD 1 ]------+----------------------------
transaction | 725
gid | personlock
prepared | 2020-05-23 15:34:47.2155+03
owner | postgres
database | testdb
locktype | relation
database | 16384
relation | 32829
page |
tuple |
virtualxid |
transactionid |
classid |
objid |
objsubid |
virtualtransaction | -1/725
pid |
mode | ShareLock
granted | t
fastpath | f
testdb=#
Vad frågan ovan gör är att säkerställa att det namngivna förberedda transaktionspersonlåset är levande och att den tillhörande bordslåsningen som innehas av denna virtuella transaktion verkligen är i det avsedda läget:DELA.
Så nu kan vi justera vyn:
CREATE OR REPLACE VIEW anonym.person AS
WITH perlockqry AS (
SELECT 1
FROM pg_prepared_xacts px,
pg_locks l0
WHERE px.gid = 'personlock'::text AND l0.virtualtransaction = ('-1/'::text || px.transaction) AND l0.relation = 'public.person'::regclass::oid AND l0.mode = 'ShareLock'::text
)
SELECT p.id,
CASE
WHEN pa.id IS NULL AND NOT (EXISTS ( SELECT 1
FROM perlockqry)) THEN p.givenname::character varying
ELSE '****'::character varying
END AS givenname,
CASE
WHEN pa.id IS NULL AND NOT (EXISTS ( SELECT 1
FROM perlockqry)) THEN p.midname::character varying
ELSE '****'::character varying
END AS midname,
CASE
WHEN pa.id IS NULL AND NOT (EXISTS ( SELECT 1
FROM perlockqry)) THEN p.surname::character varying
ELSE '****'::character varying
END AS surname,
CASE
WHEN pa.id IS NULL AND NOT (EXISTS ( SELECT 1
FROM perlockqry)) THEN p.address
ELSE '****'::text
END AS address,
CASE
WHEN pa.id IS NULL AND NOT (EXISTS ( SELECT 1
FROM perlockqry)) THEN p.email::character varying
ELSE '****'::character varying
END AS email,
p.role,
p.rank
FROM public.person p
LEFT JOIN person_anonym pa ON p.id = pa.id
Nu med den nya definitionen, om användaren har startat förberedd transaktionspersonlås, kommer följande val att returneras:
testdb=# select * from person;
id | givenname | midname | surname | address | email | role | rank
----+-----------+---------+---------+---------+-------+----------+-----------
1 | **** | **** | **** | **** | **** | Seafarer | Captain
2 | **** | **** | **** | **** | **** | IT | DBA
3 | **** | **** | **** | **** | **** | IT | Developer
(3 rows)
testdb=#
vilket betyder global ovillkorlig anonymisering.
Alla appar som försöker använda data från en bordsperson kommer att anonymiseras "****" istället för faktiska data. Låt oss nu anta att administratören för den här appen bestämmer att anonymiseringsperioden ska upphöra, så hans app utfärdar nu:
COMMIT PREPARED 'personlock';
Nu kommer alla efterföljande val att returnera:
testdb=# select * from person;
id | givenname | midname | surname | address | email | role | rank
----+-------------+---------+----------+----------------------------------------+-------------------------------+----------+-----------
1 | **** | **** | **** | **** | **** | Seafarer | Captain
2 | Achilleas | | Mantzios | Agiou Titou 10, Iraklio, Crete, Greece | [email protected] | IT | DBA
3 | Tsatsadakis | | Emanuel | Knossou 300, Iraklio, Crete, Greece | [email protected] | IT | Developer
(3 rows)
testdb=#
Varning! :Låset förhindrar samtidiga skrivningar, men förhindrar inte eventuell skrivning när låset har släppts. Så det finns en potentiell fara för att uppdatera appar, läsa "****" från databasen, en slarvig användare, trycka på uppdatering och sedan efter en tids väntan släpps det DELADE låset och uppdateringen lyckas skriva "*** *' i stället för där korrekt normaldata ska vara. Användare kan naturligtvis hjälpa till här genom att inte blint trycka på knappar, men några ytterligare skydd kan läggas till här. Uppdatering av appar kan ge ett:
set lock_timeout TO 1;
i början av uppdateringstransaktionen. På detta sätt kommer all väntan/blockering längre än 1 ms att skapa ett undantag. Vilket borde skydda mot de allra flesta fall. Ett annat sätt skulle vara en kontrollbegränsning i något av de känsliga fälten för att kontrollera mot "****"-värdet.
ALARM! :det är absolut nödvändigt att den förberedda transaktionen så småningom måste slutföras. Antingen av användaren som startade det (eller en annan användare), eller till och med av ett cron-skript som kontrollerar efter glömda transaktioner var låt oss säga 30 minuter. Att glömma att avsluta denna transaktion kommer att orsaka katastrofala resultat eftersom det förhindrar VACUUM från att köras, och naturligtvis kommer låset fortfarande att finnas där, vilket förhindrar skrivningar till databasen. Om du inte är tillräckligt bekväm med ditt system, om du inte till fullo förstår alla aspekter och alla bieffekter av att använda en förberedd/distribuerad transaktion med ett lås, om du inte har tillräcklig övervakning på plats, särskilt när det gäller MVCC mått, följ då helt enkelt inte detta tillvägagångssätt. I det här fallet kan du ha en speciell tabell som innehåller parametrar för administratörsändamål där du kan använda två speciella kolumnvärden, ett för normal drift och ett för global påtvingad anonymisering, eller så kan du experimentera med PostgreSQL-applikationsnivå delade rådgivande lås:
- https://www.postgresql.org/docs/10/explicit-locking.html#ADVISORY-LOCKS
- https://www.postgresql.org/docs/10/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS