Antes, durante e depois que o GDPR entrou em vigor em 2018, houve muitas ideias para resolver o problema de excluir ou ocultar dados do usuário, usando várias camadas da pilha de software, mas também usando várias abordagens (exclusão definitiva, exclusão suave, anonimização). A anonimização tem sido uma delas que é conhecida por ser popular entre as organizações/empresas baseadas em PostgreSQL.
No espírito do GDPR, vemos cada vez mais a exigência de documentos e relatórios comerciais trocados entre empresas, para que os indivíduos mostrados nesses relatórios sejam apresentados de forma anônima, ou seja, apenas sua função/título é mostrado , enquanto seus dados pessoais estão ocultos. Isso acontece muito provavelmente devido ao fato de que as empresas que recebem esses relatórios não querem gerenciar esses dados sob os procedimentos/processos do GDPR, eles não querem lidar com o ônus de projetar novos procedimentos/processos/sistemas para lidar com eles , e eles apenas pedem para receber os dados já pré-anônimos. Portanto, essa anonimização não se aplica apenas aos indivíduos que expressaram seu desejo de serem esquecidos, mas, na verdade, a todas as pessoas mencionadas no relatório, o que é bem diferente das práticas comuns do GDPR.
Neste artigo, vamos lidar com a anonimização para uma solução para este problema. Começaremos apresentando uma solução permanente, ou seja, uma solução em que uma pessoa que solicita o esquecimento deve ser ocultada em todas as consultas futuras no sistema. Em seguida, com base nisso, apresentaremos uma maneira de alcançar “sob demanda”, ou seja, anonimização de curta duração, o que significa a implementação de um mecanismo de anonimização destinado a estar ativo por tempo suficiente até que os relatórios necessários sejam gerados no sistema. Na solução que estou apresentando, isso terá um efeito global, portanto, esta solução usa uma abordagem gananciosa, cobrindo todos os aplicativos, com reescrita mínima (se houver) de código (e vem da tendência dos DBAs do PostgreSQL de resolver esses problemas centralmente deixando o aplicativo desenvolvedores lidam com sua verdadeira carga de trabalho). No entanto, os métodos apresentados aqui podem ser facilmente ajustados para serem aplicados em escopos limitados/mais estreitos.
Anonimização permanente
Aqui apresentaremos uma forma de conseguir anonimização. Vamos considerar a seguinte tabela contendo os registros dos funcionários de uma empresa:
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=#
Esta tabela é pública, todos podem consultá-la e pertence ao esquema público. Agora criamos o mecanismo básico para anonimização que consiste em:
- um novo esquema para armazenar tabelas e visualizações relacionadas, vamos chamá-lo de anônimo
- uma tabela contendo IDs de pessoas que querem ser esquecidas:anonym.person_anonym
- uma visualização que fornece a versão anônima de public.person:anonym.person
- configuração do search_path, para usar a nova visualização
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
;
Vamos definir o search_path para nossa aplicação:
set search_path = anonym,"$user", public;
Aviso :é essencial que o search_path esteja configurado corretamente na definição da fonte de dados no aplicativo. O leitor é encorajado a explorar maneiras mais avançadas de lidar com o caminho de pesquisa, por exemplo, com o uso de uma função que pode lidar com uma lógica mais complexa e dinâmica. Por exemplo, você pode especificar um conjunto de usuários de entrada de dados (ou função) e deixá-los continuar usando a tabela public.person durante todo o intervalo de anonimização (para que eles continuem vendo dados normais), enquanto define um conjunto de usuários gerenciais/de relatórios (ou função) para quem a lógica de anonimização será aplicada.
Agora vamos consultar nossa relação de pessoa:
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=#
Agora, vamos supor que o Sr. Singh deixe a empresa e expresse explicitamente seu direito de ser esquecido por meio de uma declaração por escrito. O aplicativo faz isso inserindo seu id no conjunto de ids “a serem esquecidos”:
testdb=# insert into anonym.person_anonym (id) VALUES(1);
INSERT 0 1
Vamos agora repetir a consulta exata que executamos antes:
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=#
Podemos ver que os detalhes do Sr. Singh não estão acessíveis no aplicativo.
Anonimização Global Temporária
A ideia principal
- O usuário marca o início do intervalo de anonimização (um curto período de tempo).
- Durante esse intervalo, somente seleções são permitidas para a tabela chamada pessoa.
- Todos os acessos (seleções) são anonimizados para todos os registros na tabela de pessoas, independentemente de qualquer configuração de anonimização anterior.
- O usuário marca o fim do intervalo de anonimização.
Blocos de construção
- Confirmação de duas fases (também conhecida como transações preparadas).
- Bloqueio explícito de tabela.
- A configuração de anonimização que fizemos acima na seção "Anonimização permanente".
Implementação
Um aplicativo de administração especial (por exemplo, chamado :markStartOfAnynimizationPeriod) executa
testdb=# BEGIN ;
BEGIN
testdb=# LOCK public.person IN SHARE MODE ;
LOCK TABLE
testdb=# PREPARE TRANSACTION 'personlock';
PREPARE TRANSACTION
testdb=#
O que o acima faz é adquirir um bloqueio na tabela no modo SHARE para que INSERTS, UPDATES, DELETES sejam bloqueados. Também iniciando uma transação de confirmação de duas fases (transação preparada AKA, em outros contextos conhecidas como transações distribuídas ou transações de arquitetura estendida XA), liberamos a transação da conexão da sessão que marca o início do período de anonimização, enquanto deixamos outras sessões subsequentes serem ciente de sua existência. A transação preparada é uma transação persistente que permanece ativa após a desconexão da conexão/sessão que a iniciou (via PREPARE TRANSACTION). Observe que a instrução “PREPARE TRANSACTION” desassocia a transação da sessão atual. A transação preparada pode ser selecionada por uma sessão subsequente e ser revertida ou confirmada. O uso desse tipo de transações XA permite que um sistema lide de maneira confiável com muitas fontes de dados XA diferentes e execute lógica transacional nessas fontes de dados (possivelmente heterogêneas). No entanto, as razões pelas quais o usamos neste caso específico:
- para habilitar a sessão do cliente emissor de encerrar a sessão e desconectar/liberar sua conexão (deixar ou pior ainda “persistente” uma conexão é uma péssima ideia, uma conexão deve ser liberada assim que for executada as consultas que ele precisa fazer)
- para tornar as sessões/conexões subsequentes capazes de consultar a existência dessa transação preparada
- para tornar a sessão final capaz de confirmar esta transação preparada (pelo uso de seu nome) marcando assim:
- a liberação do bloqueio SHARE MODE
- o fim do período de anonimização
Para verificar se a transação está ativa e associada ao bloqueio SHARE em nossa tabela de pessoas, fazemos:
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=#
O que a consulta acima faz é garantir que o bloqueio de pessoa da transação preparada nomeada esteja ativo e que, de fato, o bloqueio associado na tabela pessoa mantido por essa transação virtual esteja no modo pretendido:SHARE.
Agora podemos ajustar a visualização:
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
Agora, com a nova definição, se o usuário tiver iniciado o bloqueio pessoal de transação preparado, o seguinte select retornará:
testdb=# select * from person;
id | givenname | midname | surname | address | email | role | rank
----+-----------+---------+---------+---------+-------+----------+-----------
1 | **** | **** | **** | **** | **** | Seafarer | Captain
2 | **** | **** | **** | **** | **** | IT | DBA
3 | **** | **** | **** | **** | **** | IT | Developer
(3 rows)
testdb=#
que significa anonimização incondicional global.
Qualquer aplicativo que tente usar dados da pessoa da tabela receberá “****” anônimo em vez de dados reais reais. Agora vamos supor que o administrador deste aplicativo decida que o período de anonimização deve terminar, então seu aplicativo agora emite:
COMMIT PREPARED 'personlock';
Agora, qualquer seleção subsequente retornará:
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=#
Aviso! :O bloqueio impede gravações simultâneas, mas não impede a gravação eventual quando o bloqueio for liberado. Portanto, há um perigo potencial para atualizar aplicativos, ler '****' do banco de dados, um usuário descuidado, pressionar atualizar e, após algum período de espera, o bloqueio COMPARTILHADO é liberado e a atualização consegue escrever '*** *' no lugar de onde os dados normais corretos deveriam estar. É claro que os usuários podem ajudar aqui não pressionando os botões cegamente, mas algumas proteções adicionais podem ser adicionadas aqui. A atualização de aplicativos pode emitir um:
set lock_timeout TO 1;
no início da transação de atualização. Dessa forma, qualquer espera/bloqueio maior que 1ms gerará uma exceção. O que deve proteger contra a grande maioria dos casos. Outra maneira seria uma restrição de verificação em qualquer um dos campos confidenciais para verificar o valor '****'.
ALARME! :é imperativo que a transação preparada seja eventualmente concluída. Seja pelo usuário que o iniciou (ou outro usuário), ou mesmo por um script cron que verifica as transações esquecidas a cada 30 minutos, digamos. Esquecer de encerrar esta transação causará resultados catastróficos, pois impede a execução de VACUUM e, claro, o bloqueio ainda estará lá, impedindo gravações no banco de dados. Se você não estiver confortável o suficiente com seu sistema, se você não entender completamente todos os aspectos e todos os efeitos colaterais de usar uma transação preparada/distribuída com um bloqueio, se você não tiver um monitoramento adequado, especialmente em relação ao MVCC métricas, então simplesmente não siga essa abordagem. Nesse caso, você pode ter uma tabela especial contendo parâmetros para fins administrativos, onde você pode usar dois valores de coluna especiais, um para operação normal e outro para anonimização imposta globalmente, ou você pode experimentar bloqueios consultivos compartilhados em nível de aplicativo do PostgreSQL:
- https://www.postgresql.org/docs/10/explicit-locking.html#ADVISORY-LOCKS
- https://www.postgresql.org/docs/10/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS