Skip to content

Column masking

Column masks come from an optional rule in the policy, turned on with --policy-masks-query:

Terminal window
curral serve ... --policy-masks-query data.curral.masks
masks := {"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

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.

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) and PIVOT.

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.