Column masks¶
A ColumnMaskPolicy replaces the values of a column before the client gets them. Use it for personal data that a subject must not read in full.
Example¶
Goal: analysts see only the last four characters of a phone number.
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata:
name: mask-phone
priority: 50
spec:
match:
any:
- resources:
catalogs: ["hr"]
schemas: ["main"]
tables: ["employees"]
columns: ["phone"]
mask:
type: partial
args:
showLast: 4
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_analyst.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT id, phone FROM hr.main.employees ORDER BY id"}'
{
"query_id": "<query-id>",
"columns": ["id", "phone"],
"rows": [
[1, "*******0101"],
[2, "*******0102"]
],
"row_count": 2,
"truncated": false
}
What happened: Sluice read the table through a subquery that masks phone. The column keeps its name. The rest of the query sees only the masked value, also in WHERE and ORDER BY.
Fields¶
| Field | Type | Default | Meaning |
|---|---|---|---|
resources.columns in spec.match | list | The columns to mask. Without it, the policy masks nothing. | |
spec.mask.type | string | A mask type from the table below. | |
spec.mask.args | object | The options of the mask type. |
Shared fields: Policies. Selectors: Matching.
- Column names match in any letter case.
- A selector with
columnsand notablesmasks the column in every table. - A selector with
tagstakes the columns from aDataClassification. See Classification tags. Sluice still needs the column list of the table. - A row filter on the same table tests the real value.
Mask types¶
A mask runs in one of two places. "SQL" means the mask is part of the rewritten SQL. "Post-query" means Sluice changes the result rows after the query.
| Type | Result | args | Runs |
|---|---|---|---|
null | NULL | None. YAML needs quotes: type: "null" | SQL |
constant | A fixed value | value: required. Text, number or boolean. A list shows as text such as [1 2] | SQL |
partial | The first and last characters. maskChar replaces the rest. | showFirst, showLast: default 0, one must be above 0. maskChar: default * | SQL |
hash | A SHA-256 hash as hex text | algorithm: sha256 or hmac_sha256, default sha256. saltRef: a secret:// reference to the salt, required for hmac_sha256 | SQL for sha256, post-query for hmac_sha256 |
regex | The text with every match of pattern replaced | pattern: required, a regular expression in RE2 syntax, at most 512 bytes. replacement: default empty | SQL |
truncate | The first length characters | length: required, 1 or more. suffix: text to add after a cut | SQL |
fpe | The characters of the alphabet, encrypted. Other characters stay. | keyRef: required, a secret:// reference to the key. alphabet: numeric, lower or alphanumeric, default numeric. customAlphabet: 2 to 36 characters. tweak: hex, at most 16 bytes | Post-query |
jitter | The number, moved by up to range | range: required, between 0 and 1. 0.1 means 10 percent. seed | Post-query |
fake | An invented value | fakeType: required, one of name, first_name, last_name, email, phone, company, city, country, uuid. seed | Post-query |
hash,fpe,jitterandfakegive the same output for the same input.seedis plain text or asecret://reference. See Server config for secrets.- Another
typeand most wrongargsfailsluice policy validate. So does asaltReforkeyRefthat is not asecret://reference. It does not check the type ofvalue. Unknown keys inargspass.
More examples¶
One column per mask type¶
The table employees has two rows:
INSERT INTO employees (id, name, phone, birth_date, address, ssn, card, salary, notes, email) VALUES
(1, 'Alice Martin', '+1-555-0101', '1988-03-14', '12 Oak Street', '111-22-3333',
'4111-1111-1111-1111', 72000, 'Prefers calls in the morning', 'alice@example.com'),
(2, 'Bob Keller', '+1-555-0102', '1975-07-22', '7 Hill Road', '222-33-4444',
'5500-0000-0000-0004', 58000, 'On leave', 'bob@example.com');
mask-phone from the first example masks phone. These policies mask the other columns:
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-name }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["name"] } }] }
mask: { type: fake, args: { fakeType: name, seed: "secret://env/SLUICE_MASK_SEED" } }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-birth-date }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["birth_date"] } }] }
# Keep the year only.
mask: { type: regex, args: { pattern: '-\d\d-\d\d$', replacement: "-01-01" } }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-address }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["address"] } }] }
mask: { type: constant, args: { value: "REDACTED" } }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-ssn }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["ssn"] } }] }
mask: { type: "null" }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-card }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["card"] } }] }
mask: { type: fpe, args: { keyRef: "secret://env/SLUICE_MASK_FPE_KEY" } }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-salary }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["salary"] } }] }
mask: { type: jitter, args: { range: 0.1, seed: "secret://env/SLUICE_MASK_SEED" } }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-notes }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["notes"] } }] }
mask: { type: truncate, args: { length: 10, suffix: "..." } }
---
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata: { name: mask-email }
spec:
match: { any: [{ resources: { tables: ["employees"], columns: ["email"] } }] }
mask: { type: hash, args: { algorithm: hmac_sha256, saltRef: "secret://env/SLUICE_MASK_HMAC_KEY" } }
The secrets come from the environment:
# The fpe key must be 16, 24 or 32 bytes long.
export SLUICE_MASK_FPE_KEY="demo-fpe-key-16b"
export SLUICE_MASK_HMAC_KEY="demo-hmac-key"
export SLUICE_MASK_SEED="demo-seed"
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_analyst.supersecret" \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT * FROM hr.main.employees ORDER BY id"}'
{
"query_id": "<query-id>",
"columns": ["id", "name", "phone", "birth_date", "address", "ssn", "card", "salary", "notes", "email"],
"rows": [
[1, "Wren Jensen", "*******0101", "1988-01-01", "REDACTED", null, "6450-0314-1819-8257", 75868, "Prefers ca...", "17e8e1c5ed55183c9d382d4f35c5e7733975be4e06c656cac8d6be2c54cb968e"],
[2, "Alex Vega", "*******0102", "1975-01-01", "REDACTED", null, "8747-8067-1365-4115", 55975, "On leave", "9a2ef719edfa3b9aaa89d6ab7d2c9188fc672e0ad1de4456f86efa3823439760"]
],
"row_count": 2,
"truncated": false
}
What happened: every column shows its mask. card keeps its format. salary stays within 10 percent of the real number.
A suite shows where each mask runs:
# A masked table needs its column list. The test runner has no database.
schema:
hr.main.employees: [id, name, phone, birth_date, address, ssn, card, salary, notes, email]
cases:
- name: every column gets its mask
identity: { subject: hr-analyst, groups: [analysts] }
sql: "SELECT * FROM hr.main.employees"
expect:
outcome: allow
rewrittenSqlContains:
- "NULL AS ssn"
- "'REDACTED' AS address"
- "regexp_replace(sluice_src.birth_date::varchar, $4, $5, 'g') AS birth_date"
# Post-query masks. The SQL reads the real value.
postMasks:
- "hr.main.employees.card=fpe"
- "hr.main.employees.email=hash"
- "hr.main.employees.name=fake"
- "hr.main.employees.salary=jitter"
A sha256 mask with saltRef also needs its secret when the suite runs.
Several masks on one column¶
One mask wins. Masks never stack. Sluice picks the higher priority, then the more specific selector, then the first name in alphabetical order.
hide-phone also masks phone. mask-phone from the first example has the higher priority and wins:
apiVersion: sluice.bino.bi/v1alpha1
kind: ColumnMaskPolicy
metadata:
name: hide-phone
priority: 10
spec:
match:
any:
- resources:
columns: ["phone"]
mask:
type: "null"
- name: each column has one mask
identity: { subject: hr-analyst, groups: [analysts] }
sql: "SELECT id FROM hr.main.employees"
expect:
outcome: allow
masks:
- "hr.main.employees.address=constant"
- "hr.main.employees.birth_date=regex"
- "hr.main.employees.card=fpe"
- "hr.main.employees.email=hash"
- "hr.main.employees.name=fake"
- "hr.main.employees.notes=truncate"
# mask-phone and hide-phone match. mask-phone has the higher priority.
- "hr.main.employees.phone=partial"
- "hr.main.employees.salary=jitter"
- "hr.main.employees.ssn=null"
SQL forms refused on a post-query mask¶
A column with a post-query mask can only be a plain item of the outer select list.
| Refused form | Error code |
|---|---|
The masked column in WHERE, GROUP BY, ORDER BY, a join condition, an expression, a subquery or a CTE | ERR_MASK_UNSUPPORTED_CONTEXT |
SELECT * inside or over a subquery or a CTE. List the columns. | ERR_MASK_UNSUPPORTED_CONTEXT |
SELECT DISTINCT with the masked column. Its alias or position in another clause, for example ORDER BY 2 | ERR_MASK_UNSUPPORTED_CONTEXT |
USING and NATURAL joins, the table alias as a value, a column alias list, #3, a column with its schema | ERR_MASK_UNSUPPORTED_CONTEXT |
curl -s http://localhost:8080/v1/query \
-H "X-Api-Key: sl_demo_analyst.supersecret" \
-H "Content-Type: application/json" \
-d @- <<'JSON'
{"sql": "SELECT id FROM hr.main.employees WHERE card LIKE '4111%'"}
JSON
{
"code": "ERR_MASK_UNSUPPORTED_CONTEXT",
"message": "a post-query masked column is used outside a bare select item",
"query_id": "<query-id>"
}
Every masked table also refuses EXPLAIN and some DuckDB forms. See SQL forms refused on filtered or masked tables.
Errors on a masked table¶
| Cause | Result |
|---|---|
| The table does not exist | ERR_REWRITE_FAILED |
| Sluice cannot read the data source | ERR_DATASOURCE_UNAVAILABLE |
| A column changed after the last reload | ERR_QUERY_FAILED |
| A comparison needs the column type from before the mask | ERR_QUERY_FAILED |
The query reads rowid | ERR_QUERY_FAILED |
| A secret is missing | HTTP 500, ERR_INTERNAL |
The fpe key is not 16, 24 or 32 bytes long | HTTP 500, ERR_INTERNAL |
jitter on a text column, fpe on a very short value | HTTP 200 with a cut JSON body |
Limits¶
- Sluice keeps the column list of a masked table until a reload. Reload after a column changes.
sluice policy validatedoes not read secrets.- A
sha256hash withoutsaltRefcan be guessed for common values. Usehmac_sha256or setsaltRef.