"Who Owns the Truth" — Catalogs and the Iceberg REST Protocol
Finance wants to query gold sales, and Rohan offers them a read key for the bucket. Ingrid shows what else that key reads. The catalog as control plane: from Hive Metastore to the Iceberg REST protocol, Polaris principals, roles and grants, credential vending through STS, four engines on one table, and an analyst refused on raw by the catalog and by the storage itself.
Skim Card
One thing to remember: the catalog decides which metadata file is the table, who may load it and, with credential vending, which files an engine can touch.
- Engines don’t share a database; they share a catalog. Four engines read one gold table and returned identical totals.
- REST moved catalog logic to the server: one HTTP spec gives every engine commits, credential vending, remote signing and scan planning.
- Polaris grants privileges to catalog roles, catalog roles to principal roles, principal roles to principals. Grant per job function, never per person.
- A static bucket key ignores every grant: the analyst’s read key returned 16,917 raw rows the catalog had refused.
- Vended credentials are scoped to one table’s prefix and read-only without
TABLE_WRITE_DATA; storage refused the analyst’s writes.- Vending needs STS. SeaweedFS 4.51 provides it; decide on vending when you create a Polaris catalog.
- Patch the catalog like an auth server: Polaris 1.4.1 fixed four CVEs in credential vending, scored 9.9.
Use … When … Credential vending (STS) Engines run outside your trust boundary, or per-table access matters Remote signing (Lakekeeper; not in Polaris 1.8) Your object store has no STS, but the catalog should still gate every request Static storage keys Only trusted pipelines that already own the whole bucket Verified against: Apache Polaris 1.8.0, Iceberg REST spec at 1.12.0, SeaweedFS 4.51, Trino 484, Spark 4.1.3, DuckDB 2.0.0, PyIceberg 0.12.0 · vendor docs · 28 October 2026
Jump to: Concepts · Hands-On · Landscape · Design Drill
Story Opening
Farah’s finance analysts wanted the gold sales table in Trino, today. Rohan had a fix ready by lunch: a read-only key for the lake bucket, pasted into a Trino catalog file.
“Read-only on which prefix?” Ingrid asked.
“The bucket. It’s read-only.”
“Raw POS events are in that bucket, loyalty IDs included. The catalog will say no to raw, and your key will say yes. Whoever holds the storage keys decides who reads what. Let’s make that the catalog.”
The Quick Map
| Backend instinct | Data engineering reality | Why it matters |
|---|---|---|
| Access control lives in the database, next to the data | Data sits in object storage; the catalog can only enforce grants if engines get storage access through it | A bucket key skips every grant (step 5) |
| A connection string names the database | An engine names a catalog endpoint and a warehouse; the catalog names the current metadata file | Four engines, one current version |
| Services get long-lived credentials | Principals get OAuth tokens; storage access arrives as one-hour keys scoped to one table | A leaked vended key exposes one table, briefly |
dev and prod are separate databases | Environments are separate catalogs (or namespaces) with their own grants and storage | A shared catalog lets a test job overwrite a production table |
Concepts, with use cases
The catalog is the control plane
An Iceberg catalog maps a table name to its current metadata file and swaps that pointer atomically on commit (Part 7 opened the tree under it). Because every engine must ask it where a table is, it is also the natural place to decide who may ask, and with which storage credentials. Data plane: Parquet and Avro in object storage. Control plane: names, current versions, grants, credentials.
What breaks without it. Each engine keeps its own idea of which files are the table, and each team its own copy: the “three numbers, two answers each” incident (Part 0) started exactly like that. Larkspur: Spark and PyIceberg write, Trino and DuckDB read, all through one Polaris catalog. Elsewhere: a hospital network’s research engine and dashboards share one catalog, so a cohort table means the same files in both.
From Hive Metastore to a REST protocol
The Hive Metastore (HMS) was the first shared catalog: a Thrift service over a relational database that maps a table to a directory and its partitions. Iceberg’s Hive catalog stores the metadata pointer as a table property and leans on HMS locks for commits. Every other catalog (Glue, Nessie, JDBC) needed its own client library inside every engine, and any new catalog feature meant upgrading every engine.
The Iceberg REST catalog spec (open-api/rest-catalog-open-api.yaml in the Iceberg repository) replaces the client libraries with one HTTP protocol; the server owns the logic. At the 1.12.0 tag it covers:
- namespaces and their properties; tables (create, load, commit, rename, register, drop) and views;
- multi-table commits, commit metrics, and an
Idempotency-Keyheader for safe retries; - credential vending (
X-Iceberg-Access-Delegation: vended-credentials, plus acredentialsendpoint to refresh them) and remote signing (the catalog signs each storage request); - server-side scan planning (
planandtasks): the catalog lists the files to read, so clients skip reading manifests and the catalog can apply restrictions before any file is named; - new in 1.12: SQL function endpoints and read restrictions, a row filter and column masks the catalog returns with a table, which a reader that supports them “must fail the query” rather than ignore. Which catalogs and engines implement them is not verified (2026-10-28).
Polaris, Lakekeeper, Gravitino and Unity Catalog OSS all serve this protocol; the Landscape lists their versions.
What breaks without it. N catalogs × M engines client libraries, each at a different feature level. Larkspur: one Polaris endpoint serves four engines. Elsewhere: a SaaS company moves from Glue to a self-hosted catalog by changing one URI per engine, not a jar per cluster.
Polaris: principals, roles, privileges
Apache Polaris (1.8.0) implements the REST spec plus a management API. Its model:
- a realm is a tenant (the lab runs one,
POLARIS); a realm holds catalogs, each with a storage configuration; - a principal is an identity with OAuth client credentials: a person, a job or an engine;
- a principal role groups principals by job function; a catalog role holds privileges on securables in one catalog (
TABLE_READ_DATA,TABLE_WRITE_DATA,TABLE_CREATE,NAMESPACE_LIST,CATALOG_MANAGE_CONTENTand so on; in the lab, a grant on a namespace covered its tables). The docs defineTABLE_READ_DATAas “receiving short-lived read-only storage credentials from the catalog”: the privilege and the credential are one idea.
analyst"] --> PR["principal role
analysts"] PR --> CR["catalog role (larkspur)
gold_reader"] CR --> G1["NAMESPACE_LIST on catalog"] CR --> G2["TABLE_READ_DATA, TABLE_LIST on gold"]
Two layers of roles exist so that “analysts” can map to gold_reader in the production catalog and to something broader in a dev catalog, without touching any principal.
What breaks without it. Grants per person per table: thousands of rows nobody can audit, and a leaver’s access lingering. Larkspur: analysts read gold; pipelines write raw, silver and gold; neither may create a namespace. Elsewhere: an insurer’s fraud team reads claims tables that pricing analysts never see.
Credential vending, and why it needs STS
A grant in the catalog means nothing if the engine already holds a key for the whole bucket: it can read any file it can name. Credential vending closes the gap. When an engine loads a table and sends the delegation header, the catalog checks the grant, asks the storage’s Security Token Service (STS) for temporary credentials limited by a session policy to that table’s prefix, and returns them with the metadata. The engine holds no storage key of its own.
That needs STS: Polaris calls AssumeRole on a role it may assume, attaching the narrowing policy. AWS has it; S3-compatible stores often don’t, which is why Polaris has stsUnavailable and why this lab ran with static keys until now. SeaweedFS 4.51 ships an STS (weed/iam/sts, configured with -s3.iam.config), and the hands-on uses it. Remote signing is the alternative for stores without STS: the engine sends each request to the catalog to sign (Lakekeeper supports it; Polaris 1.8 doesn’t document it).
What breaks without it. Every engine holds a broad static key; revoking access means rotating it everywhere; one leaked config file exposes the lake. Larkspur: step 5 reads raw POS rows with a bucket key after the catalog refused them. Elsewhere: a data marketplace shares one table with a partner’s Spark cluster without handing over a bucket credential.
Environments, federation and governance hooks
- Environments. Separate catalogs for dev, stage and prod (separate storage locations, separate grants) are the safe default; namespaces (
dev_sales,prod_sales) are cheaper but share one blast radius. Larkspur’spart01–part08namespaces are lab scratch;raw,silverandgoldare the governed ones. A games studio clones prod schemas into a dev catalog with synthetic data. - Federation. One catalog front-ends others: Polaris 1.8 documents federation to Iceberg REST, Hive Metastore and BigQuery catalogs, disabled by default (no status label in the 1.8 docs; the 1.0 changelog called it experimental); Gravitino federates many catalogs under a “metalake”; Snowflake reads remote Iceberg REST catalogs through catalog-linked databases. Useful during a migration (Larkspur could front a supplier team’s Glue catalog until its tables move); each hop adds a place where grants can disagree.
- Governance hooks. Polaris emits events (Kafka and AWS CloudWatch listeners are in its configuration reference) for audit trails, supports an external authorizer through OPA (preview), and stores maintenance policies (
system.snapshot-expiry,system.data-compaction) attached to tables. Larkspur’s load and credential events will feed Part 22’s observability. Row filters and column masks are not in Polaris’s docs; Part 23 does them in Trino with OPA.
Under the Hood
What a loadTable answer carries
GET …/namespaces/gold/tables/daily_store_sales with X-Iceberg-Access-Delegation: vended-credentials returns the metadata file location, the full table metadata, a config map and, per the 1.12 spec, storage-credentials: a list of credentials per storage prefix, which clients check before config. Polaris fills both with the same keys (s3.access-key-id, s3.secret-access-key, s3.session-token, its expiry) and adds client.refresh-credentials-endpoint for renewal (step 6 prints the keys). Without the header, Polaris checks a different operation: denials name LOAD_TABLE for DuckDB (no delegation) and LOAD_TABLE_WITH_READ_DELEGATION for the others (step 5).
The PRINCIPAL_ROLE:ALL scope activates all of the principal’s roles. The session policy is built for one table and one privilege; the analyst’s (decoded from SeaweedFS’s session token, a JWT; AWS’s is opaque) has no PutObject, so a reader’s keys cannot write, whatever the engine attempts (step 5 prints it). The catalog’s own storage identity, which assumes the role and writes metadata, is correspondingly powerful: the catalog joins your list of high-value systems.
Hands-On
The code is in the every-record-counts folder of the companion-workspaces repository (tag every-record-counts/part-9). scripts/part09_catalog.py talks to Polaris’s management and catalog APIs over plain HTTP. Spark and Trino ran together here (5.8 GiB peak across all containers during make ci); each Spark or Trino step is skipped if its engine is down.
make up-trino && make up-spark # core + Trino + Sparkuv run python scripts/duck.py --local sql/part08/stage.sql # Part 8's gold file, if not staged yetuv run python -m scripts.part09_catalog resetStep 1: give the catalog something to vend with
SeaweedFS gets an IAM file (seaweedfs/iam.json, mounted with -s3.iam.config) defining one role Polaris may assume, trimmed. SeaweedFS documents that flag mainly for OIDC federation; AssumeRole by access-key users is thinly documented, so the lab’s settings come from its source and tests:
"roles": [{ "roleName": "polaris-vending", "roleArn": "arn:aws:iam::000000000000:role/polaris-vending", "trustPolicy": {"Statement": [{"Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::000000000000:user/polaris"}, "Action": ["sts:AssumeRole"]}]}, "attachedPolicies": ["lake-tables"]}]| Key | Meaning |
|---|---|
trustPolicy | Only the polaris storage identity (its own key, lake bucket only, in seaweedfs/s3.json) may assume the role |
attachedPolicies | lake-tables: s3:* on larkspur-lake/warehouse/*; every vended session is a subset of this |
sts (not shown) | Token signing key and lifetimes |
Then setup updates Part 0’s catalog: roleArn, stsEndpoint: http://seaweedfs:8333, stsUnavailable: false. From a run before compose set the flag below (setup no longer hits it):
Cannot modify AWS account ID in storage config from AwsStorageConfigurationInfo{…, stsUnavailable=true} toAwsStorageConfigurationInfo{…, roleARN=arn:aws:iam::000000000000:role/polaris-vending, …}Polaris 1.8 compares the account IDs of the old and new role ARNs: no role ARN means no account ID (null), and null ≠ 000000000000. The lab sets polaris.features."ALLOW_UNRESTRICTED_STORAGE_CONFIG_ROLE_CHANGES" for this one change (see Gotchas).
Step 2: namespaces, principals, roles, grants
uv run python -m scripts.part09_catalog setupcatalog larkspur: vends credentials by assuming arn:aws:iam::000000000000:role/polaris-vendingnamespaces: raw, silver, gold
principal principal role catalog role on privilegesanalyst analysts gold_reader catalog NAMESPACE_LIST namespace gold LIST, READ_DATApipeline pipelines lake_writer catalog NAMESPACE_LIST namespace gold CREATE, DROP, LIST, READ_DATA, WRITE_DATA namespace raw CREATE, DROP, LIST, READ_DATA, WRITE_DATA namespace silver CREATE, DROP, LIST, READ_DATA, WRITE_DATA(The TABLE_ prefix is dropped in the output.) Each line is one management call, for example a grant:
PUT /api/management/v1/catalogs/larkspur/catalog-roles/gold_reader/grants{"grant": {"type": "namespace", "namespace": ["gold"], "privilege": "TABLE_READ_DATA"}}The principals’ credentials are reset to fixed lab values (POST /principals/{name}/reset accepts a chosen client ID and secret) so Trino’s catalog file can name them.
Step 3: the pipeline writes with vended keys
uv run python -m scripts.part09_catalog loadraw.pos_events 16,917 rows written with vended key ASIAea08… (pipeline holds no lake key)gold.daily_store_sales 73,246 rows written with vended key ASIAda70… (pipeline holds no lake key)raw.pos_events is one upload day (30 September) of raw POS events; gold.daily_store_sales is Part 8’s gold table. PyIceberg, as pipeline with an S3 endpoint and no keys, received write-capable keys per table.
Step 4: four engines, one table, one principal
Each engine connects as analyst. Trino gets a second catalog file, trino/catalog/lakehouse_analyst.properties, with no storage keys:
iceberg.rest-catalog.oauth2.credential=analyst:analyst-lab-secreticeberg.rest-catalog.vended-credentials-enabled=trues3.endpoint=http://seaweedfs:8333| Key | Meaning |
|---|---|
oauth2.credential | The analyst principal’s client ID and secret: every request runs with its grants |
vended-credentials-enabled | Send the delegation header and use the returned keys; the file has no s3.aws-* keys |
s3.endpoint | Where to send storage requests; Polaris returns keys, not an endpoint |
Spark gets a second Iceberg catalog analyst through --conf (the SPARK_ANALYST settings in the script); Iceberg’s Java client doesn’t send the delegation header unless told to: spark.sql.catalog.analyst.header.X-Iceberg-Access-Delegation=vended-credentials. PyIceberg sends it by default.
uv run python -m scripts.part09_catalog enginesengine principal storage credentials store_days sales_minorPyIceberg 0.12 analyst vended 73,246 322,704,113,319DuckDB 2.0 analyst static read key 73,246 322,704,113,319Trino 484 analyst vended 73,246 322,704,113,319Spark 4.1.3 analyst vended 73,246 322,704,113,319The same 73,246 store-days and the same checksum (Part 8’s mixed-currency sales_minor) from four engines. DuckDB is the exception on credentials. In vended mode, with HTTP logging on, it sent the keys to AWS, because the answer carries no s3.endpoint (Part 1’s Gotcha is why the catalog has none):
uv run python -m scripts.part09_catalog duckdb-vendedread: HTTP Error: HTTP GET error reading 's3://larkspur-lake/warehouse/gold/daily_store_sales/metadata/snap-7047729297306965429-0-bcea317d-0301-48f5-bd00-5c1eefa501d6.avro' (HTTP 400 Bad Request)requests went to: http://localhost:8181requests went to: http://s3.us-east-1.amazonaws.comSo the lab’s DuckDB holds a static key for the lake bucket, lake-reader, and that matters in step 5.
Step 5: what the analyst may not do
uv run python -m scripts.part09_catalog accessPyIceberg read gold ok, 73,246 rowsPyIceberg read raw ForbiddenError: ForbiddenException: Principal 'analyst' with activated PrincipalRoles '[analysts]' and activated grants via '[analysts, gold_reader]' is not authorized for op LOAD_TABLE_WITH_READ_DELEGATIONPyIceberg write gold OSError: When initiating multiple part upload for key 'warehouse/gold/daily_store_sales/data/00000-0-….parquet' in bucket 'larkspur-lake': AWS Error ACCESS_DENIED during CreateMultipartUpload operation: Access Denied.DuckDB read raw HTTPException: HTTP Error: GetTableInformation endpoint returned response code Forbidden_403 with message "Principal 'analyst' with activated PrincipalRoles '[analysts]' and activated grants via '[analysts, gold_reader]' is not authorized for op LOAD_TABLE"DuckDB write gold HTTPException: HTTP Error: HTTP PUT error uploading to 's3://larkspur-lake/warehouse/gold/daily_store_sales/data/….parquet' (HTTP 403 Forbidden)Trino list schemas ok, gold, information_schema, part01, part06, part07, part08, raw, silver, systemTrino read raw Failed to load table 'pos_events' ← Forbidden: Principal 'analyst' with activated PrincipalRoles '[analysts]' and activated grants via '[analysts, gold_reader]' is not authorized for op LOAD_TABLE_WITH_READ_DELEGATIONTrino write gold Error committing write to Parquet file ← Put failed for bucket [larkspur-lake] key [warehouse/gold/daily_store_sales/data/….parquet]: software.amazon.awssdk.services.s3.model.AccessDeniedException: Access Denied. ← Access Denied.Spark read raw ForbiddenException: Forbidden: Principal 'analyst' with activated PrincipalRoles '[analysts]' and activated grants via '[analysts, gold_reader]' is not authorized for op LOAD_TABLE_WITH_READ_DELEGATIONraw files, static read key ok, 16,917 rows (loyalty_id included)gold metadata, vended key ok, 1,797 bytesraw file, vended key denied: AWS Error ACCESS_DENIED during HeadObject operationvended session policy s3:GetBucketLocation, s3:GetObject, s3:GetObjectVersion, s3:ListBucket; objects larkspur-lake/warehouse/gold/daily_store_sales/*; list prefix warehouse/gold/daily_store_sales/*(File names and request IDs shortened by the script.) Three layers answered:
- The catalog refused
rawin all four engines, with the same Polaris message. - Storage refused every write to gold: the analyst’s vended keys are read-only, and DuckDB’s static key is read-only too. The catalog was never asked to commit.
- Going around the catalog worked with the static key:
read_parquetover the raw table’s files returned all 16,917 rows. The analyst’s vended gold key, used on a raw file, was denied by SeaweedFS.
The third is Ingrid’s point, measured. NAMESPACE_LIST also shows the analyst every namespace name, including raw; names are metadata too.
Step 6: what the catalog holds
Polaris keeps one PostgreSQL row per entity; for a table, the current metadata-location plus cached schema facts.
uv run python -m scripts.part09_catalog pointeruv run python -m scripts.part09_catalog load-tablecatalog (PostgreSQL row in polaris_schema.entities, 842 bytes, entity_version 2): metadata-location = s3://larkspur-lake/warehouse/gold/daily_store_sales/metadata/00001-b46e71cd-be39-41e4-b0dc-f07c6f79cf48.metadata.json same as the REST loadTable answer: Trueobject storage (s3://larkspur-lake/warehouse/gold/daily_store_sales/): data (Parquet) 1 objects 462,765 bytes manifest (Avro) 1 objects 4,555 bytes manifest list (Avro) 1 objects 1,798 bytes metadata (JSON) 2 objects 2,777 bytesGET /catalog/v1/larkspur/namespaces/gold/tables/daily_store_sales (as analyst, X-Iceberg-Access-Delegation: vended-credentials) top-level keys: metadata-location, metadata, config, storage-credentials config keys: client.refresh-credentials-endpoint, client.region, s3.access-key-id, s3.path-style-access, s3.secret-access-key, s3.session-token, s3.session-token-expires-at-ms storage-credentials: prefix s3://larkspur-lake/warehouse/gold/daily_store_sales: s3.access-key-id, s3.secret-access-key, s3.session-token, s3.session-token-expires-at-msAn 842-byte database row decides which of 471,895 bytes in the bucket are the table. Back up that database with the bucket: without it, the files are a pile with two candidate metadata files (create and first append).
uv run pytest -q tests/test_part09.py re-runs the sequence and checks the grants, the totals, every refusal, the session policy and the loadTable keys: 12 passed in 38 s with Spark and Trino up; make ci ran 87 passed.
The Landscape
Product names and status as of 28 October 2026.
| Need | Open source | AWS | GCP | Azure | Snowflake / Databricks | Notes |
|---|---|---|---|---|---|---|
| Iceberg REST catalog | Apache Polaris 1.8, Lakekeeper 0.14, Apache Gravitino 1.3, Unity Catalog OSS 0.7, Project Nessie 0.109 (REST experimental) | Glue Data Catalog Iceberg REST endpoint; S3 Tables endpoint (no views, single-level namespaces) | Lakehouse runtime catalog (formerly BigLake metastore) Iceberg REST, GA | OneLake Iceberg REST endpoint (launched as preview; current status not stated) | Horizon Catalog (“Apache Polaris is integrated”), external read and write GA; Unity Catalog Iceberg REST | Snowflake Open Catalog is closed to new customers |
| Credential vending | Polaris (STS), Lakekeeper (STS, remote signing), Gravitino, Unity Catalog OSS, Nessie (REST experimental) | Glue: keys “bounded by the assumed IAM role and the Lake Formation grants” | Lakehouse runtime catalog in credential vending mode | OneLake via the delegation header | Horizon; Unity Catalog (recommended) | Needs STS or remote signing on the store |
| Fine-grained authorization | Polaris RBAC (OPA (preview)); Lakekeeper OpenFGA; Gravitino roles with deny | Lake Formation | not verified (2026-10-28) | not verified (2026-10-28) | Horizon; Unity Catalog | Row filters and masks: Part 23 |
| Catalog federation | Polaris (Iceberg REST, Hive, BigQuery; off by default); Gravitino metalake | not verified (2026-10-28) | BigQuery catalog federation | not verified (2026-10-28) | Snowflake catalog-linked databases; Unity Catalog foreign Iceberg tables (read-only from external engines, per Databricks docs) | Each hop is another place grants can disagree |
Tips & Gotchas
Gotcha — patch your catalog like an auth server. Polaris 1.4.1 (May 2026) fixed four CVEs, each scored CVSS 3.1 9.9: staged table creation could vend credentials for unvalidated locations (CVE-2026-42809), wildcard characters in namespace or table names could broaden vended S3 credentials (CVE-2026-42810), the GCS equivalent (CVE-2026-42811), and
write.metadata.pathwas unprotected (CVE-2026-42812). The catalog mints storage keys; track its releases as you would your identity provider’s.
Gotcha — decide on vending when you create the catalog. Polaris 1.8 refused to add a role ARN to Part 0’s catalog (step 1) although its docs say new values “can be set when none were previously configured”. The lab turns on
ALLOW_UNRESTRICTED_STORAGE_CONFIG_ROLE_CHANGESfor that; leave it off in production, where it would also let someone repoint a catalog to another account’s role.
Gotcha — revoking a grant doesn’t revoke vended keys. The next
loadTablefails at once; keys already handed out stay valid until they expire (one hour by default, cached by Polaris for 30 minutes). Keep durations short for sensitive tables, and treat removal from a role as effective within the hour, not the second.
Gotcha — no endpoint in the vended config means AWS. A client using vended keys needs to know where to send them. Polaris returns the catalog’s
endpointonly if it is set; DuckDB then fell back to AWS’s S3 endpoint. Set anendpointevery client can reach (e.g. one DNS name that resolves from host and containers; this lab has none, per Part 1), or check each engine’s behaviour before relying on vending.
Tip — name roles after jobs and data, not people. Principal roles say who (
analysts,pipelines,compliance); catalog roles say what (gold_reader,lake_writer). A new hire gets one principal-role assignment; an audit reads two short lists.
Troubleshooting
| Symptom | Likely cause | How to confirm | Fix |
|---|---|---|---|
Trino: Not authorized: for a principal whose grants are fine; Polaris logs Failed to resolve principal | The principal was deleted and re-created: Trino holds a token with the old principal ID | Polaris’s 401 lines show a principalId that differs from the one in polaris_schema.entities | Restart Trino; rotate or reset credentials instead of re-creating principals (the lab’s reset keeps them) |
user larkspur is not authorized to assume role … from SeaweedFS | weed mini runs STS but without -s3.iam.config there are no roles, and its IAM API is read-only by default | CreateRole returns “IAM write operations are disabled on this server” | Define the role in an IAM config file (step 1) |
PyIceberg create_table: Unsupported type: timestamp[us, tz=Asia/Kolkata] | DuckDB returned Arrow timestamps in the laptop’s zone | Print the Arrow schema | SET TimeZone = 'UTC' before exporting |
Design Drill
The question: “An insurance group runs one lakehouse for 12 domain teams (motor, home, life, claims, fraud, pricing, finance, marketing and others). Pipelines run on Spark, analysts use Trino, finance uses Snowflake. A compliance team must approve and audit any access to medical and claims data, and regulators can ask who read a table on a given day. Design the catalog and access model.”
Clarify first
- Must Snowflake write shared tables, or only read them?
- Is medical data limited to some tables, or columns spread across many?
- How fast must a revoked access take effect: minutes or a day?
- Does every team need its own storage bucket or account (cost attribution, residency)?
- Is there an enterprise identity provider the catalog must trust? (Assume yes: its groups map to principal roles.)
The shape of a strong answer
- One Iceberg REST catalog per environment (dev, stage, prod), separate storage per environment, plus a separate catalog for regulated medical and claims data; Polaris or a managed equivalent; Snowflake reads it through a catalog integration (a catalog-linked database), not a copy.
- Namespaces per domain and layer (
claims.raw,claims.gold); each domain team owns its writes, the platform team owns namespace creation. - Principal roles per job function (
claims_engineers,pricing_analysts,compliance_auditors); catalog roles per data product (claims_gold_reader); grants only on namespaces or tables, never per person. CATALOG_MANAGE_ACCESSis catalog-wide, so only compliance holds it on the regulated catalog; domain leads hold it on theirs. Access there is granted only after compliance approval.- Identity-provider groups map to principal roles; nobody gets a principal by hand.
- Credential vending for every engine; no human or BI tool holds a storage key; pipelines’ static keys, if any, limited to their own prefixes.
- Short credential lifetimes for sensitive namespaces; revocation documented as “effective at expiry”.
- Catalog events (loads, credential requests, grant changes) and object-storage access logs, both retained for the regulatory period: a
loadTableis not a read, and only storage logs show which files a key actually fetched. - Column masking and row filters where needed in the engines (Trino with OPA); watch the REST spec’s read restrictions as they reach catalogs and engines.
- The catalog’s database backed up with the buckets, and catalog upgrades on a security patch cadence.
Trade-offs to say out loud
- Namespace-level grants are simple but coarse: a sensitive column in a broad table forces a split table or engine-side masking.
- Vended credentials cut storage-key sprawl but add a dependency: the catalog’s availability now gates every read.
- Separate catalogs per environment cost duplicated grants; a shared catalog costs blast radius.
Common weak answers
- Bucket policies per team with static keys in every engine: the catalog’s grants then mean nothing.
- Grants per user per table: unauditable within a quarter.
- Forgetting audit: “who read this table” needs catalog events, not engine logs scattered over three products.
Further Reading
- Apache Iceberg, REST catalog OpenAPI spec (
open-api/rest-catalog-open-api.yaml, tagapache-iceberg-1.12.0). https://github.com/apache/iceberg/blob/apache-iceberg-1.12.0/open-api/rest-catalog-open-api.yaml - Apache Polaris, “Access control” and the management API spec. https://polaris.apache.org/releases/1.8.0/managing-security/access-control/
- Apache Polaris, “Catalog Federation”. https://polaris.apache.org/releases/1.8.0/federation/
- Snowflake, “Query Apache Iceberg tables with an external engine through Snowflake Horizon Catalog”. https://docs.snowflake.com/en/user-guide/tables-iceberg-access-using-external-query-engine-snowflake-horizon
- AWS, “Connecting to the Data Catalog using the AWS Glue Iceberg REST endpoint”. https://docs.aws.amazon.com/glue/latest/dg/connect-glu-iceberg-rest.html
Story Closing
Rohan deleted the bucket key before anyone used it; DuckDB’s lake-reader key was the one gap left, due for a prefix-scoped replacement. Finance got a principal, a principal role and a catalog role with three privileges on gold, and Trino got keys that last an hour and open one table. Of the five promises from Part 2, this part served consistency: four engines, one pointer, one answer.
“The grants are right,” Ingrid said. “Now look at what they protect. One Parquet file per table today; Part 7’s sales table has 123, and the micro-batches haven’t started.”
In Part 10, Rohan learns where the bytes should live: partitioning by how data is filtered, file sizes, compaction, snapshot expiry, and the partition-strategy playbook.
This is Part 9 of “Every Record Counts: Data Engineering for Backend Engineers” — Season 1: Foundations & the Lakehouse.