PostgreSQL
 sql >> Database >  >> RDS >> PostgreSQL

Anonimizzazione di PostgreSQL su richiesta

Prima, durante e dopo l'entrata in vigore del GDPR nel 2018, ci sono state molte idee per risolvere il problema dell'eliminazione o dell'occultamento dei dati degli utenti, utilizzando vari livelli dello stack software ma anche utilizzando vari approcci (cancellazione definitiva, cancellazione graduale, anonimizzazione). L'anonimizzazione è stato uno di questi che è noto per essere popolare tra le organizzazioni/aziende basate su PostgreSQL.

Nello spirito del GDPR, vediamo sempre di più l'esigenza di documenti commerciali e rapporti scambiati tra aziende, in modo che le persone mostrate in tali rapporti siano presentate in forma anonima, ovvero venga mostrato solo il loro ruolo/titolo , mentre i loro dati personali sono nascosti. Ciò accade molto probabilmente perché le aziende che ricevono queste segnalazioni non vogliono gestire questi dati secondo le procedure/processi del GDPR, non vogliono affrontare l'onere di progettare nuove procedure/processi/sistemi per gestirli , e si limitano a chiedere di ricevere i dati già pre-anonimizzati. Quindi questa anonimizzazione non si applica solo a quelle persone che hanno espresso il desiderio di essere dimenticate, ma in realtà a tutte le persone menzionate all'interno del rapporto, che è abbastanza diverso dalle pratiche comuni del GDPR.

In questo articolo, ci occuperemo dell'anonimizzazione per una soluzione a questo problema. Inizieremo presentando una soluzione permanente, ovvero una soluzione in cui una persona che chiede di essere dimenticata dovrebbe essere nascosta in tutte le future richieste nel sistema. Quindi, basandoci su questo, presenteremo un modo per ottenere "on demand", ovvero l'anonimizzazione di breve durata, il che significa l'implementazione di un meccanismo di anonimizzazione destinato a essere attivo abbastanza a lungo fino a quando i rapporti necessari non vengono generati nel sistema. Nella soluzione che sto presentando questo avrà un effetto globale, quindi questa soluzione utilizza un approccio avido, che copre tutte le applicazioni, con una riscrittura del codice minima (se presente) (e deriva dalla tendenza dei DBA di PostgreSQL a risolvere tali problemi lasciando l'app centralmente gli sviluppatori si occupano del loro vero carico di lavoro). Tuttavia, i metodi qui presentati possono essere facilmente modificati per essere applicati in ambiti limitati/più ristretti.

Anonimizzazione permanente

Qui presenteremo un modo per ottenere l'anonimizzazione. Consideriamo la seguente tabella contenente i record dei dipendenti di un'azienda:

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=#

Questa tabella è pubblica, tutti possono interrogarla e appartiene allo schema pubblico. Ora creiamo il meccanismo di base per l'anonimizzazione che consiste in:

  • un nuovo schema per contenere tabelle e viste correlate, chiamiamo questo anonimo
  • una tabella contenente gli ID delle persone che vogliono essere dimenticate:anonym.person_anonym
  • una vista che fornisce la versione anonima di public.person:anonym.person
  • impostazione del percorso_ricerca, per utilizzare la nuova vista
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
;

Impostiamo il search_path alla nostra applicazione:

set search_path = anonym,"$user", public;

Avviso :è essenziale che il percorso_ricerca sia impostato correttamente nella definizione dell'origine dati nell'applicazione. Il lettore è incoraggiato a esplorare modi più avanzati per gestire il percorso di ricerca, ad es. con l'uso di una funzione che può gestire una logica più complessa e dinamica. Ad esempio è possibile specificare un insieme di utenti (o ruoli) di immissione dati e lasciare che continuino a utilizzare la tabella public.person durante l'intervallo di anonimizzazione (in modo che continuino a vedere i dati normali), mentre si definisce un insieme di utenti gestionali/di segnalazione (o ruolo) per il quale si applicherà la logica di anonimizzazione.

Ora interroghiamo la nostra relazione personale:

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=#

Ora, supponiamo che il signor Singh lasci l'azienda ed esprima esplicitamente il suo diritto all'oblio con una dichiarazione scritta. L'applicazione lo fa inserendo il suo ID nel set di ID "da dimenticare":

testdb=# insert into anonym.person_anonym (id) VALUES(1);
INSERT 0 1

Ripetiamo ora la query esatta che abbiamo eseguito prima:

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=#

Possiamo vedere che i dettagli del signor Singh non sono accessibili dall'applicazione.

Anonimizzazione globale temporanea

L'idea principale

  • L'utente segna l'inizio dell'intervallo di anonimizzazione (un breve periodo di tempo).
  • Durante questo intervallo, sono consentite solo selezioni per la tabella denominata persona.
  • Tutti gli accessi (selezioni) sono resi anonimi per tutti i record nella tabella persona, indipendentemente da qualsiasi precedente configurazione di anonimizzazione.
  • L'utente segna la fine dell'intervallo di anonimizzazione.

Mattoni

  • Commissione a due fasi (aka Transazioni preparate).
  • Blocco esplicito della tabella.
  • L'impostazione dell'anonimizzazione che abbiamo fatto sopra nella sezione "Anonimizzazione permanente".

Implementazione

Un'app di amministrazione speciale (ad es. chiamata :markStartOfAnynimizationPeriod) esegue 

testdb=# BEGIN ;
BEGIN
testdb=# LOCK public.person IN SHARE MODE ;
LOCK TABLE
testdb=# PREPARE TRANSACTION 'personlock';
PREPARE TRANSACTION
testdb=#

Quello che fa sopra è acquisire un blocco sul tavolo in modalità CONDIVISIONE in modo che INSERTI, AGGIORNAMENTI, CANCELLAZIONI siano bloccati. Anche avviando una transazione di commit in due fasi (transazione preparata da AKA, in altri contesti noti come transazioni distribuite o transazioni XA di Architettura estesa) liberiamo la transazione dal collegamento della sessione che segna l'inizio del periodo di anonimizzazione, lasciando che altre sessioni successive siano consapevole della sua esistenza. La transazione preparata è una transazione persistente che rimane attiva dopo la disconnessione della connessione/sessione che l'ha avviata (tramite PREPARE TRANSACTION). Si noti che l'istruzione "PREPARE TRANSACTION" dissocia la transazione dalla sessione corrente. La transazione preparata può essere prelevata da una sessione successiva ed essere sottoposta a rollback o commit. L'uso di questo tipo di transazioni XA consente a un sistema di gestire in modo affidabile molte origini dati XA diverse ed eseguire la logica transazionale su tali origini dati (possibilmente eterogenee). Tuttavia, i motivi per cui lo utilizziamo in questo caso specifico :

  • per consentire alla sessione client di emissione di terminare la sessione e disconnettere/liberare la sua connessione (lasciare o peggio ancora "persistente" una connessione è davvero una cattiva idea, una connessione dovrebbe essere liberata non appena esegue le query che deve fare)
  • per rendere successive sessioni/connessioni in grado di interrogare l'esistenza di questa transazione preparata
  • per rendere la sessione finale in grado di commettere questa transazione preparata (tramite l'uso del suo nome) contrassegnando così:
    • il rilascio del blocco SHARE MODE
    • la fine del periodo di anonimizzazione

Per verificare che la transazione sia attiva e associata al blocco SHARE sulla nostra tabella delle persone, eseguiamo:

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=#

Quello che fa la query precedente è garantire che il blocco persona della transazione preparata con nome sia attivo e che effettivamente il blocco associato sulla persona del tavolo tenuto da questa transazione virtuale sia nella modalità prevista:CONDIVIDI.

Quindi ora possiamo modificare la vista:

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

Ora con la nuova definizione, se l'utente ha avviato il blocco della transazione preparato, verrà restituito il seguente select:

testdb=# select * from person;
id | givenname | midname | surname | address | email |   role   |   rank   
----+-----------+---------+---------+---------+-------+----------+-----------
  1 | ****  | **** | **** | **** | ****  | Seafarer | Captain
  2 | ****  | **** | **** | **** | ****  | IT   | DBA
  3 | ****  | **** | **** | **** | ****  | IT   | Developer
(3 rows)

testdb=#

che significa anonimizzazione globale incondizionata.

Qualsiasi app che tenti di utilizzare i dati della persona della tabella verrà anonimizzata "****" invece dei dati reali effettivi. Ora supponiamo che l'amministratore di questa app decida che il periodo di anonimizzazione sta per scadere, quindi la sua app ora emette:

COMMIT PREPARED 'personlock';

Ora qualsiasi selezione successiva restituirà:

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=#

Attenzione! :Il blocco impedisce scritture simultanee, ma non impedisce eventuali scritture quando il blocco sarà stato rilasciato. Quindi c'è un potenziale pericolo per l'aggiornamento delle app, la lettura di "****" dal database, un utente negligente, l'aggiornamento e quindi, dopo un certo periodo di attesa, il blocco CONDIVISO viene rilasciato e l'aggiornamento riesce scrivendo "*** *' al posto di dove dovrebbero essere i dati normali corretti. Gli utenti ovviamente possono aiutare qui non premendo alla cieca i pulsanti, ma qui è possibile aggiungere alcune protezioni aggiuntive. L'aggiornamento delle app potrebbe causare:

set lock_timeout TO 1;

all'inizio della transazione di aggiornamento. In questo modo qualsiasi attesa/blocco superiore a 1 ms genererà un'eccezione. Che dovrebbe proteggere dalla stragrande maggioranza dei casi. Un altro modo sarebbe un vincolo di controllo in uno qualsiasi dei campi sensibili per verificare il valore "****".

ALLARME! :è indispensabile che la transazione predisposta debba essere eventualmente completata. O dall'utente che lo ha avviato (o da un altro utente), o anche da uno script cron che controlla le transazioni dimenticate ogni diciamo 30 minuti. Dimenticare di terminare questa transazione causerà risultati catastrofici poiché impedisce l'esecuzione di VACUUM e, naturalmente, il blocco sarà ancora presente, impedendo le scritture nel database. Se non sei abbastanza a tuo agio con il tuo sistema, se non comprendi appieno tutti gli aspetti e tutti gli effetti collaterali dell'utilizzo di una transazione preparata/distribuita con un blocco, se non hai un monitoraggio adeguato, soprattutto per quanto riguarda l'MVCC metriche, quindi semplicemente non seguire questo approccio. In questo caso, potresti avere una tabella speciale contenente parametri per scopi di amministrazione in cui potresti utilizzare due valori di colonna speciali, uno per il normale funzionamento e uno per l'anonimizzazione globale forzata, oppure potresti sperimentare i blocchi di avviso condivisi a livello di applicazione PostgreSQL:

  • https://www.postgresql.org/docs/10/explicit-locking.html#ADVISORY-LOCKS
  • https://www.postgresql.org/docs/10/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS