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