Column masking
Column masks come from an optional rule in the policy, turned on with
--policy-masks-query:
curral serve ... --policy-masks-query data.curral.masksmasks := {"lake.analytics.customers": {"ssn": "last:2", "name": "redact", "birth_date": "null"}} if { not "pii_reader" in input.roles}The rule returns {table: {column: mask}} for the current request.
| Mask | Result |
|---|---|
"null" |
NULL, keeping the column type |
"redact" |
'***' |
"last:N" |
only the last N characters are visible |
{"sql": "<expression>"} |
any SQL expression, for special cases |
No oracle
Section titled “No oracle”Masking happens in a subquery that runs before the user’s query. A
WHERE ssn = '123-45-6789', a join or a GROUP BY operates on the masked
value, so the real value cannot be found by elimination.
/v1/schema flags masked columns with "masked": true.
What gets denied
Section titled “What gets denied”Row filters and masks only cover queries curral can rewrite with certainty.
Everything else touching a protected table is denied (403,
decided_by: protection):
- non-SELECT statements that read a protected table (
INSERT ... SELECT,CREATE TABLE AS); - reading the table indirectly, through a view or macro, including a local view over a lake table;
- queries that do not survive the SQL → tree → SQL round trip intact;
TABLESAMPLE, time travel (AT) andPIVOT.
Verified, and cheap
Section titled “Verified, and cheap”A differential test compares every protected query with the same query over a copy of the table that is already filtered and masked; results must be identical. It runs in CI, and a fuzzer runs weekly.
Rewrites are cached per query text and rules. Measured over HTTP on a point lookup:
| none | RLS | mask | RLS + mask | |
|---|---|---|---|---|
| local, p50 | 1.3 ms | 1.7 ms | 1.7 ms | 1.8 ms |
| Iceberg on R2, p50 | 7.2 ms | 8.6 ms | 9.7 ms | 8.7 ms |
On aggregations the difference disappears into the query’s own time.