Przed, w trakcie i po wejściu RODO w 2018 r. pojawiło się wiele pomysłów na rozwiązanie problemu usuwania lub ukrywania danych użytkownika, przy użyciu różnych warstw stosu oprogramowania, ale także przy użyciu różnych podejść (twarde usuwanie, miękkie usuwanie, anonimizacja). Anonimizacja była jedną z nich, o której wiadomo, że jest popularna wśród organizacji/firm opartych na PostgreSQL.
W duchu RODO coraz częściej dostrzegamy wymóg wymiany dokumentów biznesowych i raportów między firmami, tak aby osoby pokazane w tych raportach były prezentowane w sposób anonimowy, tj. pokazywana była tylko ich rola/tytuł , a ich dane osobowe są ukryte. Dzieje się tak najprawdopodobniej dlatego, że firmy otrzymujące te raporty nie chcą zarządzać tymi danymi w ramach procedur/procesów RODO, nie chcą zajmować się ciężarem projektowania nowych procedur/procesów/systemów do ich obsługi , a oni po prostu proszą o otrzymanie danych już wstępnie zanonimizowanych. Tak więc ta anonimizacja dotyczy nie tylko tych osób, które wyraziły chęć bycia zapomnianym, ale w rzeczywistości wszystkich osób wymienionych w raporcie, co znacznie różni się od powszechnych praktyk RODO.
W tym artykule zajmiemy się anonimizacją w celu rozwiązania tego problemu. Zaczniemy od przedstawienia rozwiązania stałego, czyli takiego, w którym osoba żądająca zapomnienia powinna być ukryta we wszystkich przyszłych zapytaniach w systemie. Następnie, uzupełniając to, przedstawimy sposób na uzyskanie „na żądanie”, czyli krótkotrwałej anonimizacji, czyli wdrożenia mechanizmu anonimizacji, który ma działać tylko wystarczająco długo, aż do wygenerowania potrzebnych raportów w systemie. W rozwiązaniu, które prezentuję, będzie to miało efekt globalny, więc to rozwiązanie wykorzystuje podejście zachłanne, obejmujące wszystkie aplikacje, z minimalnym (jeśli w ogóle) przepisywaniem kodu (i pochodzi z tendencji DBA PostgreSQL do rozwiązywania takich problemów centralnie opuszczając aplikację programiści zajmują się ich prawdziwym obciążeniem pracą). Jednak przedstawione tutaj metody można łatwo dostosować do zastosowania w ograniczonych/węższych zakresach.
Stała anonimizacja
Tutaj przedstawimy sposób uzyskania anonimizacji. Rozważmy poniższą tabelę zawierającą akta pracowników firmy:
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=#
Ta tabela jest publiczna, każdy może ją przeszukiwać i należy do schematu publicznego. Teraz tworzymy podstawowy mechanizm anonimizacji, który składa się z:
- nowy schemat do przechowywania powiązanych tabel i widoków, nazwijmy to anonimem
- tabela zawierająca identyfikatory osób, które chcą zostać zapomniane:anonym.person_anonym
- widok udostępniający anonimową wersję public.person:anonym.person
- konfiguracja ścieżki wyszukiwania, aby użyć nowego widoku
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
;
Ustawmy ścieżkę wyszukiwania do naszej aplikacji:
set search_path = anonym,"$user", public;
Ostrzeżenie :ważne jest, aby ścieżka search_path została poprawnie skonfigurowana w definicji źródła danych w aplikacji. Zachęcamy czytelnika do poznania bardziej zaawansowanych sposobów obsługi ścieżki wyszukiwania, m.in. z wykorzystaniem funkcji, która może obsługiwać bardziej złożoną i dynamiczną logikę. Na przykład możesz określić zestaw użytkowników do wprowadzania danych (lub ról) i pozwolić im na korzystanie z tabeli public.person przez cały okres anonimizacji (aby nadal widzieć normalne dane), jednocześnie definiując zestaw użytkowników zarządzających/raportujących (lub roli), dla których będzie obowiązywać logika anonimizacji.
Teraz zapytajmy o relację osoby:
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=#
Załóżmy teraz, że pan Singh opuszcza firmę i wyraźnie wyraża swoje prawo do bycia zapomnianym w pisemnym oświadczeniu. Aplikacja robi to, wstawiając jego identyfikator do zestawu identyfikatorów „do zapomnienia”:
testdb=# insert into anonym.person_anonym (id) VALUES(1);
INSERT 0 1
Teraz powtórzmy dokładnie zapytanie, które uruchomiliśmy wcześniej:
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=#
Widzimy, że dane pana Singha nie są dostępne z aplikacji.
Tymczasowa globalna anonimizacja
Główna idea
- Użytkownik zaznacza początek interwału anonimizacji (krótki okres).
- W tym okresie dozwolone są tylko wybory dla tabeli o nazwie osoba.
- Cały dostęp (wybory) są anonimizowane dla wszystkich rekordów w tabeli osoby, niezależnie od jakiejkolwiek wcześniejszej konfiguracji anonimizacji.
- Użytkownik zaznacza koniec okresu anonimizacji.
Bloki konstrukcyjne
- Zatwierdzanie dwufazowe (inaczej przygotowane transakcje).
- Wyraźne blokowanie tabeli.
- Konfiguracja anonimizacji, którą wykonaliśmy powyżej w sekcji „Stała anonimizacja”.
Wdrożenie
Wykonana jest specjalna aplikacja administracyjna (np. o nazwie:markStartOfAnynimizationPeriod)
testdb=# BEGIN ;
BEGIN
testdb=# LOCK public.person IN SHARE MODE ;
LOCK TABLE
testdb=# PREPARE TRANSACTION 'personlock';
PREPARE TRANSACTION
testdb=#
Powyższe działanie polega na zablokowaniu tabeli w trybie UDOSTĘPNIANIE, dzięki czemu WSTAWIANIE, AKTUALIZACJA i USUWANIE są blokowane. Również uruchamiając dwufazową transakcję zatwierdzającą (transakcja przygotowana AKA, w innych kontekstach zwanych transakcjami rozproszonymi lub transakcjami eXtended Architecture XA) uwalniamy transakcję od połączenia sesji oznaczającej początek okresu anonimizacji, jednocześnie pozwalając innym kolejnym sesjom być świadomy jego istnienia. Przygotowana transakcja jest trwałą transakcją, która pozostaje aktywna po rozłączeniu połączenia/sesji, która ją uruchomiła (poprzez PREPARE TRANSACTION). Należy pamiętać, że wyciąg „PREPARE TRANSACTION” odłącza transakcję od bieżącej sesji. Przygotowana transakcja może zostać odebrana przez kolejną sesję i albo wycofana, albo zatwierdzona. Zastosowanie tego rodzaju transakcji XA umożliwia systemowi niezawodne radzenie sobie z wieloma różnymi źródłami danych XA i wykonywanie logiki transakcyjnej w tych (prawdopodobnie heterogenicznych) źródłach danych. Jednak powody, dla których używamy go w tym konkretnym przypadku:
- aby umożliwić wychodzącej sesji klienta zakończenie sesji i rozłączenie/zwolnienie jego połączenia (pozostawienie lub co gorsza „utrzymywanie” połączenia to naprawdę zły pomysł, połączenie powinno zostać zwolnione, gdy tylko się wykona zapytania, które musi wykonać)
- aby kolejne sesje/połączenia były zdolne do wysyłania zapytań o istnienie tej przygotowanej transakcji
- aby końcowa sesja była w stanie dokonać tej przygotowanej transakcji (poprzez użycie jej nazwy) oznaczając w ten sposób:
- uwolnienie blokady SHARE MODE
- koniec okresu anonimizacji
W celu sprawdzenia, czy transakcja jest aktywna i powiązana z blokadą SHARE na naszej tabeli osoby, wykonujemy:
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=#
To, co powyższe zapytanie zapewnia, to zapewnienie, że nazwana, przygotowana blokada transakcji jest aktywna i że rzeczywiście powiązana blokada na stole osoby utrzymywanej przez tę wirtualną transakcję jest w zamierzonym trybie:UDOSTĘPNIJ.
Więc teraz możemy dostosować widok:
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
Teraz z nową definicją, jeśli użytkownik rozpoczął przygotowywaną transakcję personlock, zwróci następujący wybór:
testdb=# select * from person;
id | givenname | midname | surname | address | email | role | rank
----+-----------+---------+---------+---------+-------+----------+-----------
1 | **** | **** | **** | **** | **** | Seafarer | Captain
2 | **** | **** | **** | **** | **** | IT | DBA
3 | **** | **** | **** | **** | **** | IT | Developer
(3 rows)
testdb=#
co oznacza globalną bezwarunkową anonimizację.
Każda aplikacja próbująca użyć danych osoby przy stole otrzyma anonimowe „****” zamiast rzeczywistych danych rzeczywistych. Załóżmy teraz, że administrator tej aplikacji zdecydował, że okres anonimizacji dobiega końca, więc jego aplikacja wyświetla teraz:
COMMIT PREPARED 'personlock';
Teraz każdy kolejny wybór zwróci:
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=#
Ostrzeżenie! :Blokada zapobiega równoczesnym zapisom, ale nie zapobiega ewentualnemu zapisowi, gdy blokada zostanie zwolniona. Istnieje więc potencjalne niebezpieczeństwo aktualizacji aplikacji, odczytania „****” z bazy danych, nieostrożnego użytkownika, uruchomienia aktualizacji, a następnie po pewnym okresie oczekiwania zostaje zwolniona blokada SHARED i aktualizacja udaje się napisać „*** *' w miejscu, gdzie powinny być prawidłowe normalne dane. Użytkownicy oczywiście mogą w tym pomóc, nie naciskając na ślepo przycisków, ale można tu dodać kilka dodatkowych zabezpieczeń. Aktualizacja aplikacji może spowodować:
set lock_timeout TO 1;
na początku transakcji aktualizacji. W ten sposób każde oczekiwanie/blokowanie dłuższe niż 1ms zgłosi wyjątek. Co powinno chronić przed zdecydowaną większością przypadków. Innym sposobem byłoby ograniczenie sprawdzania w dowolnym z wrażliwych pól w celu sprawdzenia wartości „****”.
ALARM! :konieczne jest, aby przygotowana transakcja została ostatecznie zakończona. Albo przez użytkownika, który go uruchomił (lub innego użytkownika), albo nawet przez skrypt cron, który sprawdza zapomniane transakcje co, powiedzmy, 30 minut. Zapomnienie o zakończeniu tej transakcji spowoduje katastrofalne skutki, ponieważ uniemożliwi uruchomienie VACUUM i oczywiście blokada nadal będzie tam, uniemożliwiając zapisy do bazy danych. Jeśli nie czujesz się wystarczająco dobrze ze swoim systemem, jeśli nie rozumiesz w pełni wszystkich aspektów i wszystkich skutków ubocznych korzystania z przygotowanej/rozproszonej transakcji z blokadą, jeśli nie masz odpowiedniego monitoringu, zwłaszcza jeśli chodzi o MVCC metryki, to po prostu nie stosuj tego podejścia. W takim przypadku możesz mieć specjalną tabelę zawierającą parametry do celów administracyjnych, w której możesz użyć dwóch specjalnych wartości kolumn, jednej do normalnego działania i jednej do globalnej wymuszonej anonimizacji, lub możesz poeksperymentować ze współdzielonymi blokadami doradczymi na poziomie aplikacji PostgreSQL:
- https://www.postgresql.org/docs/10/explicit-locking.html#ADVISORY-LOCKS
- https://www.postgresql.org/docs/10/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS