Writing · multicloud-governance-platform
The Pull Request That Leaked PII Never Merged

Most data governance is a report. Somebody runs a scan, a dashboard turns amber, a ticket gets filed — and the grant that exposed a schema of customer emails has already been live for three weeks.
I built the other version. Here, a pull request that grants a group SELECT on a schema classified pii turns the check red before review. Make that check required in branch protection and the red becomes a wall: it does not merge. The exposure never happens, so there is no incident to report on.
The check that fails before merge

The check is a deterministic analyzer — plain Python, standard library. It runs with no cloud and no credentials, in a CI job that holds no secrets at all, in about a second.
That last detail is the one that matters architecturally. Because the gate needs nothing from the cloud, it cannot be sidestepped by not deploying. It runs before deploying is a thing that could happen. A governance control that only executes at apply time is a control that a developer can avoid by simply not applying.
Nine rules, four of them gating. And the same four gating rules are re-implemented in OPA/Rego and run in CI against the analyzer's own output — so a bug in one engine cannot silently pass the other.
I attack the gate on every run
A gate nobody has attacked is a gate nobody has tested. So on every run, the repository fires six crafted violations at its own check, each mutation landing in a throwaway copy, and every one must be blocked:
- PII read by a broad group.
- PII written by a non-admin.
- A grant to every principal in the account.
- An exception left to rot — both documented PII exceptions backdated to yesterday, so the suppressed findings must come back.
- A grant on a schema that does not exist — one character wrong, nobody notices.
- A workflow file GitHub cannot parse — a bug this repo actually shipped.

Attack 4 is the one I'd point a compliance officer at. It doesn't test whether the rule works; it tests whether the expiry mechanism works, by aging the paperwork rather than changing the grant.
This is the pattern I keep returning to across my projects: prove the control can fail, not just that it passes. Green is what a working control looks like. It is also what a disconnected one looks like.
Governance isn't "no". It's "not without a reason — and not forever"
A gate that can only say no gets disabled by the first team with a deadline. So the gate is also a ledger:
// environments/dev/policy_exceptions.json — abridged
{
"rule": "PII_BROAD_READ",
"object": "schema:sales_rds_fed.crm",
"principal": "crm_managers",
"justification": "CRM operations require read access to service accounts.
Read-only (SELECT), scoped to crm_managers, covered by DPIA-2026-014.",
"approved_by": "data-protection-officer",
"expires": "2026-12-31"
}
Read the last line again. On 1 January 2027, that exception stops suppressing its finding and CI goes red again.
That is not a bug to be fixed with a longer expiry. It is the entire design. Every exception any organisation has ever granted has one thing in common: nobody remembers it. Here the forgetting is impossible, because the build breaks. Somebody has to look at the justification again, decide whether it still holds, and sign it again — or remove the grant.
Fix the grant or document it as a dated exception, and the same checks go green:

I'd argue this is the most transferable idea in the whole project. Access exceptions with mandatory expiry dates enforced by a build turns access review from an annual fire drill into a background process.
One JSON contract, three clouds, two engines
The gate works because there is exactly one place that describes what exists and what is sensitive.
One JSON document per domain declares its storage, catalogs, schemas, grants, and the classification of everything in it. Terragrunt reads that JSON natively — jsondecode(file(...)), no code generation, no Python translating it into anything — and turns it into Unity Catalog objects across AWS, Azure and GCP, and into Snowflake grants alongside them.
| Clouds · domains | 3 (AWS · Azure · GCP) · 3 (sales, supply_chain, marketing) |
| Contract files | 6 (one *_infra.json + one *_grants.json per domain) |
| Securables | 30: 7 external locations, 6 catalogs, 13 schemas, 4 volumes |
| Grants | 70, across 8 groups |
| PII schemas | 2. (Azure holds none — and the copilot later says so rather than inventing one.) |
| Terraform modules · ADRs · tests | 87 · 16 · 137, infrastructure-free, gating every push |
The classification is an additive convention: Terraform ignores it entirely, because the modules consume the JSON with jsondecode + lookup, so unknown keys pass straight through. Adding a classification changes no apply. It only changes what the gate knows. That separation is why governance metadata could be introduced without touching a single resource.
The minimisation is visible in the graph

Nobody drew this. Unity Catalog followed the SQL.
Read it left to right: Postgres and SQL Server enter as federated sources — live views, no copy, queries running in the source engine. They become bronze, then silver, then gold. A Delta-Shared table arrives from a different Databricks account on GCP. All three converge into one executive table.
Now look at customers: it carries full_name, email, phone. Look at the table immediately downstream: segment and signup_year. The PII minimisation is a visible property of the lineage graph, not a claim in a document.
A promise you can rerun
The strings email, phone and full_name appear in no SELECT list anywhere in the pipeline. So the PII claim isn't a promise — it's a query:

The identities still exist. They live in the source Postgres, queried in place through federation — and reaching one requires the crm_managers grant, which required the signed, dated, expiring exception from earlier.
That's the shape I'd want in a regulated business: the sensitive data never moves, the access to it is a single grant, and that grant has an expiry date in version control.
A quality gate that publishes what it refused

The sources are seeded deliberately dirty, because a source that arrives clean makes the cleansing stage theatre.
Silver flags every bad row by rule and reports the counts: 120 with no market, 61 non-positive amounts, 40 replays. It drops those — and it keeps the 28 orphaned orders, relabelled unknown rather than discarded, and reports them anyway.
Publishing the orphan count is the deliberate part. A governance platform that hides its own exceptions is not one.
Three clouds, no public path

Three private-endpoint rules on one workspace, all ESTABLISHED. AWS RDS reports publicly_accessible = false — the instance has no public address at all. Azure SQL reports publicNetworkAccess = Disabled. And both still answer live queries, because the traffic is arriving over the private path.
This needed a piece of engineering that I think is genuinely interesting. Databricks serverless runs inside an AWS Databricks account, and a network-connectivity rule there can only ever create an AWS endpoint. There is no way to ask it for a private endpoint into Azure SQL or BigQuery — the feature does not exist. So the problem moved to ground where it does:
Databricks serverless (AWS)
└─ private endpoint → internal load balancer → Fargate proxy
└─ IPsec VPN → Azure private endpoint / Google's private API VIP
The proxies are TCP passthroughs. They terminate nothing, hold no credential, and understand no protocol — the TLS session is end-to-end between Databricks and the database, and the proxy carries bytes it cannot read.
And the proof is at the packet level rather than in the configuration: 131 data-carrying sessions originating from private 10.x address space, up to 1.9 MB each, where a health check would carry zero. Configuration says what was intended; flow logs say what moved.
The honest footnote, which I put in the README too: BigQuery has no "disable public access" switch. It's a Google-managed API; there is nothing to turn off. What is private on that path is the connection. Removing BigQuery's public API surface entirely is VPC Service Controls, which this project does not do.
One contract, a second engine, zero copies
Everything above is Databricks. The same JSON also governs Snowflake, reading the same bytes.
Databricks writes the gold layer once, as Parquet, to S3. Snowflake reads that same object in place, through an external table over an external stage:

metadata$filename returns the identical S3 key Databricks wrote. Same six markets, same revenue, a different engine, and not one byte copied.The external location in Unity Catalog and the stage in Snowflake even share a name, because both are generated from the same contract.
The governance travels with it: the contract drives Snowflake's roles, grants, resource monitors and column masking too. One role sees real email addresses; another runs the identical query and sees ***MASKED***.
That demo table generates its own synthetic PII, in a schema the contract does not declare — and it has to, because the governed catalogs hold nothing to mask. The PII never left Postgres. Which is the point, and the reason the demonstration has to invent some.
The copilot that knows what it may not know
There's a Genie space over four read-only governance tables generated from the same JSON — objects, access matrix, PII map, policy findings. That's its entire world. It holds no credential, reads no business data, and cannot grant anything.
The ordering is explicit and, I'd argue, the only defensible one:
policy_analyzer.py → decides what is safe (it fails the PR)
governance_report.py → documents it
genie_space.py → lets a human ask in English (read-only convenience)
The analyzer decides. The LLM only restates what the analyzer already proved.

That refusal is the feature. And it isn't a promise made in a prompt — Genie queries as the human asking, so Unity Catalog's own grants are the hard ceiling on anything it can return. A prompt can be talked around. A grant cannot.
Asked which datasets hold PII across all three clouds, it answers with a table, footnoted citations, and a Show code link to the SQL. It also reports Azure — no PII datasets rather than inventing one to fill the row.
What this is worth to a business
Prevention has no incident report, which is exactly why it's undervalued. The pull request above never merged, so there is no breach to disclose, no forensic exercise, no notification clock. That is the entire return, and it is invisible by construction.
Access exceptions with expiry dates solve the problem access reviews are trying to solve. An annual review asks people to remember every grant. An expiry date makes the system remember, and makes forgetting break the build.
"We can't prove it" is the expensive answer to a regulator. Here the answers are executable: a query that returns zero rows, a lineage graph the platform drew itself, flow logs showing traffic that never touched the internet.
One contract across three clouds and two engines is a procurement position, not just an architecture. When governance lives in each platform's own console, changing vendors means rebuilding it. When it's one JSON document, the engine is a rendering target.
What this does not do
A portfolio that lists only what works is a sales page.
prod/ has never been applied — it mirrors dev/ file-for-file, and the architecture supports promotion by config diff, but nobody has done it. Live drift detection is implemented and unit-tested against synthetic data, and has never run against a real Unity Catalog — a grant changed by hand in the UI would not be caught. The gate does not fail on MEDIUM findings: six are open today and CI is green, a deliberate posture rather than a hidden one. And the source OLTP systems are simulated, with the boundary written down as its own decision record, because a real platform does not own its sources and pretending otherwise would make everything else suspect.
Every one of those is in the README under a heading that says so.
The takeaway
Governance fails when it is a description of a system rather than a property of it. Descriptions drift, get exceptions nobody tracks, and produce findings after the exposure.
Three things make it a property instead: put the check where it runs before the resource exists, make every exception expire, and make the claims executable — a query anyone can rerun, not a paragraph anyone can dispute.
Full repo — the contract, the analyzer, the OPA cross-check, the ADRs, and the evidence: https://github.com/theofanis-tsakanikas/multicloud-governance-platform
If you work in data governance: what happens today when someone grants access to a sensitive schema? Does something turn red, or does someone find out later? I'd like to hear where teams have managed to move that check earlier.
One of a series of write-ups on the projects in my portfolio — each one a reference implementation of the trust layer that makes data and AI safe to ship.
The code, the CI and every test behind this article are public.