Skip to content

Commit 4da954e

Browse files
valvesssclaude
andcommitted
HAULDR-10: fase A — hauldr.data_classification, o rótulo por coluna (ADR 0053)
O rótulo de sensibilidade × categoria que a frota nunca teve, numa tabela dentro de cada project DB. Aditivo: create table if not exists + create or replace view, nada de DROP. Aplicada a 33/33 projetos vivos (28 pg17 + 5 pg16) por psql direto, com a linha no ledger _hauldr_migrations, então o applyMigrations a pula. O compose do hauldr NÃO foi redeployado (watch_paths pinado) — o PG da frota não piscou. Escolha de armazenamento, medida e não opinada: - SECURITY LABEL FOR ccl → 'provider "ccl" is not loaded'. Exige extensão C + shared_preload_libraries + restart dos dois clusters. Sequestrar o provider pgsodium (carregado, aceita label) é pior: ele AGE sobre os labels dele. - COMMENT ON COLUMN → ocupado por prosa humana real, e não carrega ator/data. Custo aceito: a linha sobrevive ao DROP COLUMN. Aceitável porque a deriva é computável (view hauldr.data_classification_status: labeled/unlabeled/orphan) e o ato que a falsifica já está no hauldr_audit.ddl_log com o autor real — provado E2E: DROP COLUMN virou 'orphan' e logou 'public.clientes.email'. Sem grants, de propósito: PostgREST está pinado em public, então a tabela em hauldr é inalcançável por construção. Provado: anon e authenticated batem em 'permission denied for schema hauldr'. O mapa do dado sensível não é servível. A view lê pg_catalog, não information_schema — este último é filtrado pelo privilégio do caller e sub-reportaria, que é a única falha que uma view de governança não pode ter. Linha de base: 6.908 colunas de tabela base, 0% de cobertura. Essa é a fila. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7b0f311 commit 4da954e

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

migrations/0006_classification.sql

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
-- Data classification — the per-column label the fleet never had (ADR 0053).
2+
--
3+
-- WHY THIS EXISTS
4+
--
5+
-- The fleet knows how to PROTECT data and cannot say WHAT IT IS. Measured on
6+
-- 2026-07-17: 8.638 columns across 868 tables, and zero carry a label. There is
7+
-- no query, anywhere, that answers "where does CPF live?".
8+
--
9+
-- That gap is not cosmetic. Four dimensions of ADR 0042 (6 data-subject rights,
10+
-- 7 retention, 8 ROPA, 13 minimisation) and P2 of ADR 0051 (PII scrub in the dev
11+
-- lane) are ALL blocked on it, and none of them named the dependency. You cannot
12+
-- erase what you cannot locate; you cannot write retention "by data type" with no
13+
-- data type; ROPA (art. 37) is literally a report over this table.
14+
--
15+
-- The primitive is real enough that the house already invented it TWICE, by hand,
16+
-- in incompatible formats: `db_urdr.items.body_internal` carries a prose COMMENT
17+
-- that IS a classification label, and `lofn/lib/urdr/data.ts` (COLS_CLIENTE) is a
18+
-- per-column allowlist in TypeScript. Two independent encarnations of the same
19+
-- missing thing is the classic signal.
20+
--
21+
-- WHY A TABLE, AND NOT THE TWO OBVIOUS CATALOG SLOTS
22+
--
23+
-- `SECURITY LABEL` is the formal mechanism and it does not run here. Measured:
24+
--
25+
-- SECURITY LABEL FOR ccl ON COLUMN t.cpf IS 'restricted';
26+
-- ERROR: security label provider "ccl" is not loaded
27+
--
28+
-- A provider needs a C extension calling register_label_provider(), an entry in
29+
-- shared_preload_libraries and a restart of BOTH clusters — to store the same
30+
-- string in a different catalog. Hijacking the `pgsodium` provider (which IS
31+
-- loaded, and accepts labels) would be worse: pgsodium ACTS on its own labels
32+
-- (transparent column encryption). Revisit only alongside the `anon` extension,
33+
-- which brings its own provider and consumes exactly this label for masking.
34+
--
35+
-- `COMMENT ON COLUMN` is taken. The fleet's 30 comments are real human prose
36+
-- ("Percentual de comissão sobre o PREÇO DE CUSTO (0-100)"), and a comment cannot
37+
-- carry WHO labelled and WHEN — which is half of what governance means. COMMENT
38+
-- stays for humans.
39+
--
40+
-- The cost of choosing a table: a COMMENT dies with its column, a row does not,
41+
-- so a label can outlive what it describes. That is acceptable because the drift
42+
-- is COMPUTABLE (see the view below) and the act that falsifies it — ADD/DROP
43+
-- COLUMN — is already logged, with its real author, in hauldr_audit.ddl_log
44+
-- (0004). An orphan row is a finding; a silently vanished comment is nothing.
45+
--
46+
-- WHY THE `hauldr` SCHEMA, AND WHY NO GRANTS
47+
--
48+
-- Unlike 0005, this ledger ships with NO grants, on purpose. PostgREST is pinned
49+
-- to PGRST_DB_SCHEMAS=public, so a table in `hauldr` is unreachable by anon and
50+
-- authenticated by construction — the map of where the sensitive data lives must
51+
-- not itself be servable. Only the control-plane, which connects as the owner,
52+
-- reads and writes it. Default-deny by placement, not by policy.
53+
--
54+
-- The label travels in pg_dump, which is the whole reason it lives inside the
55+
-- project DB rather than in a central catalog: it survives backup, restore and
56+
-- the pg16 → pg17 cutover (cutover.ts already carries hauldr_audit across). A
57+
-- central catalog desynchronises from the schema on the first restore.
58+
59+
create table if not exists hauldr.data_classification (
60+
schema_name text not null,
61+
table_name text not null,
62+
column_name text not null,
63+
64+
-- Two axes, because they answer two different questions and neither derives
65+
-- from the other: a CPF and a salary are both `confidential`; only one is
66+
-- personal data.
67+
--
68+
-- sensitivity — the ENFORCEMENT axis (how bad if it leaks):
69+
-- public safe to serve to anon
70+
-- internal CCL sees it; the client not necessarily — never anon
71+
-- confidential the tenant's business data — anon never; authenticated only via RLS
72+
-- restricted personal / credential / financial — RLS, never in a log,
73+
-- mandatory scrub in dev, subject to rights and retention
74+
sensitivity text not null
75+
check (sensitivity in ('public', 'internal', 'confidential', 'restricted')),
76+
77+
-- category — the LEGAL/semantic axis, for ROPA (art. 37) and data-subject
78+
-- rights (art. 18). `pii_sensitive` is LGPD art. 5 II (health, biometrics,
79+
-- race) and is not a luxury: db_zyramed is health data and has its own regime.
80+
category text not null
81+
check (category in ('pii', 'pii_sensitive', 'financial', 'credential', 'business', 'none')),
82+
83+
-- How the label got here. `heuristic` is a PROPOSAL and must never be trusted
84+
-- as an answer: a regex without a negative control does not measure, it
85+
-- confirms (`token_count` matches /token/ and is a billing integer). A human
86+
-- promotes it to `human`; `migration` is the end state, where the author
87+
-- declares the label next to ADD COLUMN and the heuristic becomes a safety net.
88+
source text not null check (source in ('human', 'heuristic', 'migration')),
89+
90+
set_by text not null,
91+
set_at timestamptz not null default now(),
92+
93+
primary key (schema_name, table_name, column_name)
94+
);
95+
96+
-- The map. One definition in SQL so the panel, the patrol rule and the fleet
97+
-- roll-up cannot drift into three subtly different answers.
98+
--
99+
-- Reads pg_catalog, NOT information_schema, and that is deliberate:
100+
-- information_schema.columns is filtered by the caller's privileges, so it
101+
-- silently UNDER-REPORTS — the one failure mode a governance view must not have.
102+
--
103+
-- Scoped to `public`: that is what PostgREST exposes and what varies per project.
104+
-- `auth.users.email` (GoTrue) and storage.* also hold PII, but they are identical
105+
-- in every project and owned by upstream — a named deferral, not an oversight.
106+
--
107+
-- state:
108+
-- labeled column exists and carries a label
109+
-- unlabeled column exists, no label — the DEFAULT, and it is a FINDING, never
110+
-- an approval. Absence of a label means unknown, not public.
111+
-- orphan label exists, column is gone — drift, detectable, reportable
112+
create or replace view hauldr.data_classification_status as
113+
select
114+
n.nspname::text as schema_name,
115+
c.relname::text as table_name,
116+
a.attname::text as column_name,
117+
format_type(a.atttypid, a.atttypmod) as data_type,
118+
dc.sensitivity,
119+
dc.category,
120+
dc.source,
121+
dc.set_by,
122+
dc.set_at,
123+
case when dc.column_name is null then 'unlabeled' else 'labeled' end as state
124+
from pg_class c
125+
join pg_namespace n on n.oid = c.relnamespace
126+
join pg_attribute a on a.attrelid = c.oid
127+
left join hauldr.data_classification dc
128+
on dc.schema_name = n.nspname
129+
and dc.table_name = c.relname
130+
and dc.column_name = a.attname
131+
where n.nspname = 'public'
132+
and c.relkind = 'r'
133+
and a.attnum > 0
134+
and not a.attisdropped
135+
136+
union all
137+
138+
select
139+
dc.schema_name,
140+
dc.table_name,
141+
dc.column_name,
142+
null,
143+
dc.sensitivity,
144+
dc.category,
145+
dc.source,
146+
dc.set_by,
147+
dc.set_at,
148+
'orphan'
149+
from hauldr.data_classification dc
150+
where not exists (
151+
select 1
152+
from pg_class c
153+
join pg_namespace n on n.oid = c.relnamespace
154+
join pg_attribute a on a.attrelid = c.oid
155+
where n.nspname = dc.schema_name
156+
and c.relname = dc.table_name
157+
and a.attname = dc.column_name
158+
and c.relkind = 'r'
159+
and a.attnum > 0
160+
and not a.attisdropped
161+
);

0 commit comments

Comments
 (0)