File-layer masking without STS (e.g. Hetzner Object Storage)
The problem
File-layer masking is the airtight tier: the proxy materialises per-(table, mask-signature) masked Parquet exports under data/__masked__/…, and masked
principals are meant to read those while the raw base bytes are physically
403. On backends with STS, a masked principal gets a short-lived session
token scoped to the masked-signature prefix, so this is turnkey.
Backends with no STS at all (Hetzner Object Storage) have no per-request
scoped tokens — only long-lived static keys whose access is governed by the
bucket policy (see REFERENCE.md, no-STS section). A masked
principal can’t be handed a scoped STS token, and ducklake-credentials
fails closed for a masked principal holding a plain static key: it can’t
prove at vend time that the key is confined to the current masked-signature
prefix (signatures rotate when a policy changes), so it refuses rather than risk
a base-byte read. The consequence is that the masked export is unreadable too —
the table returns zero rows for that principal. Airtight, but unusable.
Confined static keys
A confined static key resolves this. Register the reader’s key with
confined=true to attest that the bucket policy keeps it confined:
- Deny
s3:GetObjecton every file-layer table’s base prefix, and - Allow
s3:GetObjecton only the current masked-signature export prefix (plus any non-file-layer prefixes the principal may read).
That layout is exactly what python -m duckicelake.hetzner_policy emits, and it
is kept current as signatures rotate by re-running it. Given the attestation,
ducklake-credentials vends the key to file-layer-masked principals
("enforcement": "bucket-policy-confined") instead of failing closed: the raw
bytes stay physically unreadable, so serving the masked export is safe.
// POST /v1/{prefix}/governance/static-s3-keys
{
"principal": "alice",
"access-key-id": "KU2MB3JY2B1ZX374EYHU",
"secret-access-key": "…", // optional; enables turnkey vending
"confined": true
}
confined defaults to false (the pre-existing fail-closed behaviour), so
nothing changes for existing keys until you opt in.
Confinement strength
- Cross-project key (strongest). Mint the reader key in a different
Hetzner project. Cross-project keys start with no access, so the
Allowstatements are positive confinement — the key can reach exactly the granted prefixes and nothing else. - Same-project key. Project-scoped keys already have full bucket access, so
Allowis redundant and only theDenycarve-outs enforce. Still airtight for the raw base prefix, but theDenydenies that prefix to every principal sharing the key — you can’t have one principal masked and another see raw on the same key. Use cross-project keys for per-principal differentiation.
Turnkey setup
For a table <schema>.<table> in catalog <catalog>, masked for principals who
hold key <key>:
# 1. Register the reader key(s) as confined
curl -XPOST …/v1/lake/governance/static-s3-keys \
-d '{"principal":"alice","access-key-id":"<key>","secret-access-key":"…","confined":true}'
# 2. Author a file-layer masking policy + attach it (via tag or column)
curl -XPOST …/v1/lake/governance/masking-policies \
-d '{"name":"mask_amt","signature":"(val DOUBLE)","body":"0.0","file-layer-masking":true}'
curl -XPOST …/v1/lake/governance/policy-attachments \
-d '{"policy-kind":"masking","policy-name":"mask_amt","target-kind":"tag",
"tag-namespace":"pii","tag-name":"secret"}'
# 3. Materialise the export (any vend for the table does this)
curl …/v1/<catalog>/namespaces/<schema>/ducklake-credentials?table=<table>&principal=alice
# 4. Lay down the bucket policy (Deny base + Allow masked-sig)
python -m duckicelake.hetzner_policy --apply --catalog <catalog>
How reads resolve (readable and airtight)
- Unqualified reference (
SELECT … FROM events): the transparent-maskingsearch_pathroutes it to the masked view, which reads the export — masked values, readable with the confined key. The catalog-wide vend (a?table-less DuckLake-direct ATTACH, e.g. a query engine that can’t name a single table) also routes every file-layer table to its export view, so single-node / multi-table readers get masked data, not an empty base read. - Schema-qualified reference (
SELECT … FROM main.events): resolves to the raw base table, whose bytes the bucket policy denies → the read returns nothing (never cleartext). This is the airtight guarantee the cooperative (view-only) tier can’t give.
Cross-catalog note (multi-tenant)
Governance authoring is gated to the default catalog (settings.catalog_name,
e.g. lake), but policies key by (schema, table) and apply to per-account
catalogs (e.g. acme__main). Two consequences:
- The masked-export SELECT is qualified against the catalog the export
connection actually ATTACHes (
ref.catalog_name), not the global default — otherwise it fails with “Catalog ’lake’ does not exist” and silently falls back to cooperative masking. hetzner_policyscans one catalog’s tables per run, so apply it per account catalog:--catalog acme__main. (A multi-catalog sweep over all provisioned catalogs is a reasonable enhancement.)
Caveats & operations
- Re-run
hetzner_policywhen a policy changes. A new mask shape rotates the signature → a new export prefix. Until the bucket policy is re-applied toAllowthe new prefix, masked reads fail closed (empty), never leak. - Distributed over a single export file falls back to single-node execution (still masked + readable); sharding kicks in once an export spans ≥2 files.
- Time travel is denied on file-layer tables (there’s no per-historical masked export) — see REFERENCE.md.