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 keysOnly 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 instinctData engineering realityWhy it matters
Access control lives in the database, next to the dataData sits in object storage; the catalog can only enforce grants if engines get storage access through itA bucket key skips every grant (step 5)
A connection string names the databaseAn engine names a catalog endpoint and a warehouse; the catalog names the current metadata fileFour engines, one current version
Services get long-lived credentialsPrincipals get OAuth tokens; storage access arrives as one-hour keys scoped to one tableA leaked vended key exposes one table, briefly
dev and prod are separate databasesEnvironments are separate catalogs (or namespaces) with their own grants and storageA 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-Key header for safe retries;
  • credential vending (X-Iceberg-Access-Delegation: vended-credentials, plus a credentials endpoint to refresh them) and remote signing (the catalog signs each storage request);
  • server-side scan planning (plan and tasks): 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_CONTENT and so on; in the lab, a grant on a namespace covered its tables). The docs define TABLE_READ_DATA as “receiving short-lived read-only storage credentials from the catalog”: the privilege and the credential are one idea.
graph LR P["principal
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’s part01–part08 namespaces are lab scratch; raw, silver and gold are 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).

sequenceDiagram participant E as Engine (as analyst) participant P as Polaris participant S as SeaweedFS STS participant O as Object storage E->>P: loadTable + vended-credentials header P->>P: token → principal roles → catalog roles → TABLE_READ_DATA on gold? P->>S: AssumeRole(polaris-vending, session policy: read gold/daily_store_sales/*) S-->>P: temporary key, secret, session token P-->>E: metadata + storage-credentials E->>O: GET data files with the temporary key

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.

Terminal window
make up-trino && make up-spark # core + Trino + Spark
uv run python scripts/duck.py --local sql/part08/stage.sql # Part 8's gold file, if not staged yet
uv run python -m scripts.part09_catalog reset

Step 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"]
}]
KeyMeaning
trustPolicyOnly the polaris storage identity (its own key, lake bucket only, in seaweedfs/s3.json) may assume the role
attachedPolicieslake-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} to
AwsStorageConfigurationInfo{…, 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

Terminal window
uv run python -m scripts.part09_catalog setup
catalog larkspur: vends credentials by assuming arn:aws:iam::000000000000:role/polaris-vending
namespaces: raw, silver, gold
principal principal role catalog role on privileges
analyst analysts gold_reader catalog NAMESPACE_LIST
namespace gold LIST, READ_DATA
pipeline 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

Terminal window
uv run python -m scripts.part09_catalog load
raw.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-secret
iceberg.rest-catalog.vended-credentials-enabled=true
s3.endpoint=http://seaweedfs:8333
KeyMeaning
oauth2.credentialThe analyst principal’s client ID and secret: every request runs with its grants
vended-credentials-enabledSend the delegation header and use the returned keys; the file has no s3.aws-* keys
s3.endpointWhere 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.

Terminal window
uv run python -m scripts.part09_catalog engines
engine principal storage credentials store_days sales_minor
PyIceberg 0.12 analyst vended 73,246 322,704,113,319
DuckDB 2.0 analyst static read key 73,246 322,704,113,319
Trino 484 analyst vended 73,246 322,704,113,319
Spark 4.1.3 analyst vended 73,246 322,704,113,319

The 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):

Terminal window
uv run python -m scripts.part09_catalog duckdb-vended
read: 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:8181
requests went to: http://s3.us-east-1.amazonaws.com

So 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

Terminal window
uv run python -m scripts.part09_catalog access
PyIceberg read gold ok, 73,246 rows
PyIceberg 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_DELEGATION
PyIceberg 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, system
Trino 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_DELEGATION
Trino 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_DELEGATION
raw files, static read key ok, 16,917 rows (loyalty_id included)
gold metadata, vended key ok, 1,797 bytes
raw file, vended key denied: AWS Error ACCESS_DENIED during HeadObject operation
vended 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:

  1. The catalog refused raw in all four engines, with the same Polaris message.
  2. 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.
  3. Going around the catalog worked with the static key: read_parquet over 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.

Terminal window
uv run python -m scripts.part09_catalog pointer
uv run python -m scripts.part09_catalog load-table
catalog (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: True
object 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 bytes
GET /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-ms

An 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.

NeedOpen sourceAWSGCPAzureSnowflake / DatabricksNotes
Iceberg REST catalogApache 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, GAOneLake Iceberg REST endpoint (launched as preview; current status not stated)Horizon Catalog (“Apache Polaris is integrated”), external read and write GA; Unity Catalog Iceberg RESTSnowflake Open Catalog is closed to new customers
Credential vendingPolaris (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 modeOneLake via the delegation headerHorizon; Unity Catalog (recommended)Needs STS or remote signing on the store
Fine-grained authorizationPolaris RBAC (OPA (preview)); Lakekeeper OpenFGA; Gravitino roles with denyLake Formationnot verified (2026-10-28)not verified (2026-10-28)Horizon; Unity CatalogRow filters and masks: Part 23
Catalog federationPolaris (Iceberg REST, Hive, BigQuery; off by default); Gravitino metalakenot verified (2026-10-28)BigQuery catalog federationnot 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.path was 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_CHANGES for 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 loadTable fails 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 endpoint only if it is set; DuckDB then fell back to AWS’s S3 endpoint. Set an endpoint every 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

SymptomLikely causeHow to confirmFix
Trino: Not authorized: for a principal whose grants are fine; Polaris logs Failed to resolve principalThe principal was deleted and re-created: Trino holds a token with the old principal IDPolaris’s 401 lines show a principalId that differs from the one in polaris_schema.entitiesRestart 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 SeaweedFSweed mini runs STS but without -s3.iam.config there are no roles, and its IAM API is read-only by defaultCreateRole 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 zonePrint the Arrow schemaSET 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_ACCESS is 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 loadTable is 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

  1. Apache Iceberg, REST catalog OpenAPI spec (open-api/rest-catalog-open-api.yaml, tag apache-iceberg-1.12.0). https://github.com/apache/iceberg/blob/apache-iceberg-1.12.0/open-api/rest-catalog-open-api.yaml
  2. Apache Polaris, “Access control” and the management API spec. https://polaris.apache.org/releases/1.8.0/managing-security/access-control/
  3. Apache Polaris, “Catalog Federation”. https://polaris.apache.org/releases/1.8.0/federation/
  4. 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
  5. 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.