Column and Row Policies¶
Mid-Market, from enterprise 0.22.0. Off unless TDB_POLICIES_FILE is set.
A policy decides what each key sees of a source: which columns, which of them masked,
and which rows. Two keys can query the same table and get different answers, on every
path: POST /v1/query, every MCP tool, views, and the
PostgreSQL wire gateway. The audit log records which
rule shaped each query.
TDB enforces a policy in the database: it rewrites the SQL so that every table the
query reads is replaced by a subquery holding only the allowed columns, masks and rows.
So COUNT(*), upper(ssn), joins and WHERE ssn LIKE '9%' all see policy data, not
the raw table. Nothing changes in your database.
What it looks like¶
A patients table in PostgreSQL, and two keys. clinician sees everything.
support-emea sees only EMEA patients, the last four characters of each SSN, and no
diagnosis:
version: 1
sources:
clinic: # the registered source name
default: deny # a key no rule names gets nothing
rules:
- name: emea-support
applies_to: {keys: [support-emea]}
tables:
patients:
columns: [id, name, region, ssn] # diagnosis is withheld
mask: {ssn: partial}
rows: "region = :region" # the key's own region only
- name: clinicians
applies_to: {keys: [clinician]}
tables:
patients: {} # every column, every row
docker run ... -e TDB_POLICIES_FILE=/config/policies.yml -v /srv/tdb:/config:ro tdb-enterprise:<version>
# give support-emea its region (admin)
curl -X PUT http://localhost:8000/v1/auth/keys/<support-emea id>/attributes \
-H "Authorization: Bearer <ADMIN_KEY>" -H "Content-Type: application/json" \
-d '{"attributes": {"region": "EMEA"}}'
The same query, SELECT * FROM patients ORDER BY id, as each key:
{"id":1,"name":"Ann Lee","region":"EMEA","ssn":"123-45-6789","diagnosis":"asthma"}
{"id":2,"name":"Bo Chen","region":"APAC","ssn":"987-65-4321","diagnosis":"diabetes"}
{"id":3,"name":"Cy Okafor","region":"EMEA","ssn":"555-12-3456","diagnosis":"migraine"}
{"id":4,"name":"Di Ruiz","region":"AMER","ssn":"222-33-4444","diagnosis":"hypertension"}
For support-emea, SELECT count(*) FROM patients is 2, and SELECT diagnosis FROM
patients fails with the database's own column "diagnosis" does not exist: a withheld
column is indistinguishable from one that is not there. A key no rule names, under
default: deny, is refused (Refused by policy: no policy rule covers this key on this
source). The audit entry for the support-emea query:
{"event": "query", "policy_rule": "emea-support",
"policy_masked": {"patients": ["ssn"]}, "policy_withheld": {"patients": ["diagnosis"]},
"policy_row_filtered": ["patients"], "policy_sha256": "0b55e457d539…"}
The governed access recipe runs this end to end.
The policy file¶
YAML, set with TDB_POLICIES_FILE and read once at startup. An invalid file stops
TDB from starting, with the reason: serving without the rules someone wrote is the
unsafe failure. An unknown field is an error, so a typo cannot silently turn a rule off.
Check a loaded file with GET /v1/policies.
| Field | Meaning |
|---|---|
version |
1 |
sources.<name> |
A registered source, by name. Sources the file does not name are unaffected |
default |
Required. allow or deny: what a caller no rule names gets on this source. allow = today's behaviour, unrestricted |
rules[].name |
Shown in the audit log. Unique per source |
rules[].applies_to |
roles: [read, readwrite, admin], keys: [key names] and/or, from 0.24.0, groups: [IdP groups] (single sign-on). A key name beats a group, and a group beats a role; a person in two groups gets the first matching rule in the file. A role, key or group in two rules of one source is refused at load |
rules[].tables.<table> |
The tables this rule's callers may query. Any other table, view or copy on the source is refused for them |
columns |
The visible columns. A column added to the database later is hidden until you list it. Omit (with hidden) for all columns |
hidden |
Instead of columns: the withheld columns. A column added later is visible |
mask |
{column: redact \| partial \| hash} — see below |
rows |
A row filter, written in the source's SQL dialect. :name is a caller attribute |
Key names, not IDs. Rotation issues a new key ID and keeps the name (and the key's attributes), so a rule follows a key through rotation.
Masks¶
| Mask | Returns | Example |
|---|---|---|
redact |
[redacted] |
[redacted] |
partial |
*** and the last 4 characters |
***6789 |
hash |
Hex SHA-256 of the value | 01a54629efb9… (for 123-45-6789) |
hash is not anonymisation
The hash is unkeyed. A value with few possibilities (an SSN, a date of birth, a
postcode) can be recovered by hashing every possible value and comparing. Use
hash to join or count distinct values without showing them, not to hide them.
Row filters and caller attributes¶
rows: "region = :region" is evaluated per caller. Each :name is bound, as an escaped
literal, to the caller's attribute of that name:
- API keys:
PUT /v1/auth/keys/{id}/attributes(admin) with{"attributes": {"region": "EMEA"}}. Up to 20 attributes; names start with a letter or_(letters, digits,_, at most 64 characters); values are strings of up to 200 characters.{}clears them. Shown onGET /v1/auth/keys. - JWTs: an
attributesclaim, an object of string values:"attributes": {"region": "EMEA"}.
A caller whose rule needs an attribute it does not have is refused
(policy_missing_attribute), never served unfiltered.
Who a rule applies to¶
TDB finds the caller's rule on that source by key name first, then by role:
| Caller | Matched by |
|---|---|
| DB-managed API key | its name, then its role |
Static key (TDB_API_KEYS) |
role admin |
| JWT | its role claim (default read) |
No rule → the source's default. So default: deny with rules only for read refuses
admin keys too: give admins a rule (tables: {t: {}} is unrestricted access to t) or
use default: allow.
How queries are enforced¶
For a caller with a rule, TDB parses the SQL, replaces each table reference with the
policy subquery, and runs the result. Withheld columns are not in the subquery, so
SELECT * leaves them out on its own.
It fails closed. Anything TDB cannot fully account for is refused with an audited reason, never passed through:
- a table the rule does not name, including a view over a protected table;
- a function that is not on TDB's allow-list for policied callers (functions that run SQL text or read files, for example);
- a table expression it cannot place, another schema or database, more than one
statement,
SELECT INTO, locking clauses; - SQL it cannot parse.
Callers without a rule on the source are unaffected: no parsing, no allow-list.
Verified against real PostgreSQL, MySQL and SQL Server, and DuckDB (CSV, Parquet and, from 0.23.0, DuckDB database sources). Snowflake has not yet been verified against a real Snowflake account.
What a policied caller sees¶
| Surface | Behaviour |
|---|---|
POST /v1/query, MCP tools, views |
Rewritten as above. Views run under the caller's rule |
GET /v1/sources/{ref}/schema, schema_source |
Withheld columns and unlisted tables are left out; a masked column carries "masked": "<mask>" |
| SQL errors | Only the reason (column "x" does not exist). The engine's context lines would quote TDB's rewritten SQL |
| Registering or deleting sources | Refused for a non-admin key a policy restricts (policy_registry_change): a copy under a new name would carry no rule. Admin keys are exempt |
Through the PostgreSQL wire gateway¶
Statements are rewritten in both protocols, including the query inside DECLARE.
Drivers and BI tools first read the catalog (pg_catalog, information_schema) to list
tables and columns, so a query that reads only catalog tables passes as written.
Catalogs that hold data rather than metadata (pg_stats and similar) and functions that
run SQL text are refused. Verified with psql 16, Metabase (pgjdbc) and
SQLAlchemy + psycopg 3.
Known limit: names are visible through the gateway
Through the gateway a policied caller can see the names of withheld columns and of tables and views outside its rule, plus approximate table row counts, in the catalog. Their values stay protected: querying them fails. REST and MCP schema responses do not show them. Tell us if column names are sensitive for you.
Audit¶
While a policy file is loaded, every query and denial entry carries
policy_sha256, the SHA-256 of the file. A query run under a rule adds:
| Field | Value |
|---|---|
policy_rule |
The rule's name |
policy_masked |
{table: [masked columns]} |
policy_withheld |
{table: [withheld columns]} |
policy_row_filtered |
[tables a row filter applied to] |
They are present, possibly empty, on every query under a rule. A denial under a rule
carries policy_rule. The denial reasons are listed under
Errors & audit reasons.
Checking a policy file¶
GET /v1/policies (admin) returns the loaded file's path, SHA-256, and each source's
default and rules. With ?validate=true it also reads each source and reports:
problems: a table or column the rules name that the source does not have, or a source name that is not registered;warnings: a view that reads a protected table while the source'sdefaultisallow, so callers with no rule read the view in full (PostgreSQL, MySQL, SQL Server).
Suggested policies¶
tdb policy suggest <source> (or GET /v1/sources/{ref}/policy-suggestions, admin)
flags columns that look like personal data and prints a draft policy file:
# patients.ssn government_id -> partial ('ssn')
# patients.diagnosis health -> hidden ('diagnosis')
version: 1
sources:
clinic:
default: allow
rules:
- name: suggested
applies_to:
roles: [read]
tables:
patients:
columns: [id, name, region, ssn]
mask: {ssn: partial}
- Columns are judged by name and type only. No value is read, so personal data in
a free-text column (
notes) is not found. - Categories: secrets, government IDs, identity documents, financial accounts, medical record numbers, health data, special-category data, dates of birth, contact details, network addresses, postal addresses, personal names, locations, compensation.
- The draft lists every column of every table for the
readrole, so it loads as written.hashis never suggested. - Suggestions only: nothing is enforced until you set
TDB_POLICIES_FILE. Available on every commercial licence.
Licence¶
Policies are a Mid-Market feature. Licences carry a tier; a licence issued before
0.22.0 has none and counts as Mid-Market. An SMB licence with TDB_POLICIES_FILE set
stops TDB from starting, with a message that names both ways out: unset the variable,
or ask us for a Mid-Market licence. TDB never serves a source without the rules written
for it.
Limits¶
- A row filter is SQL in the source's dialect; TDB binds attributes but does not check the filter's logic. Test it with the key it is for.
- Denials raised after the rewrite (a query timeout, a privileged database role) carry
policy_sha256but notpolicy_rule. - Policies apply to reading data. Who may register sources and manage keys is RBAC.