No Grant, One Key, Every File

The three authorisation planes of a lakehouse, tested against each other: a read-only catalog principal refused at commit after the files were already written, a static storage key that read every table with no catalog involved, vended credentials that held exactly to their table, an engine whose fixed catalog identity turned every user into root, and a view that was no policy at all.

A principal with no grant on any catalog, holding the platform’s static object-store key, pointed at the bookshop’s curated orders table through DuckDB.

  duckdb read_parquet('s3://warehouse/wild_hdi/stage15/orders_curated/data/*.parquet'):   120,150 rows
  duckdb: every table under the catalog's prefix:                                        9 tables
  duckdb: COPY (SELECT 1) TO 's3://warehouse/wild_hdi/stage15/orders_curated/data/planted.parquet':   written

No catalog was consulted, because none was needed. The table has 60,075 rows. The prefix has twice that, because the copy-on-write merges of chapter 12 left their old files on the store until an expiry that has not run. The same key that read every table also wrote a file into one of them, where it will sit invisible until a procedure like chapter 14’s add_files makes it real. Every grant on the catalog was in place — and every one of them was beside the point.

That is the shape of authorisation on a lakehouse: three planes that each decide separately, and a request that has to pass only one of them. The storage plane is the object store’s IAM, which knows about keys and prefixes and nothing about tables. The catalog plane is Polaris’s principals, roles and grants, which know about tables and namespaces and nothing about files. The engine plane is whatever identity Spark, Trino or DuckDB brings to the other two, which is sometimes the user’s and sometimes the engine’s own. A read-only principal gives the tests below a concrete question: where is the read-only decision enforced, and which routes can bypass it? Credential vending closes the storage route; the engine’s choice of identity needs its own attention.

The catalog plane holds, at the commit

A principal analyst on the Polaris catalog, in a principal role analysts, mapped to a catalog role readers with TABLE_READ_DATA and the list privileges, and its own client credentials. Then a Spark session as that principal, still carrying the lab’s static storage key, because the catalog vends none.

  analyst: SELECT count(*) FROM po.stage15.orders_curated:    OK -> 60,075
  analyst: INSERT INTO po.stage15.orders_curated:             FAILED -> ForbiddenException: Principal 'analyst' with activated
           PrincipalRoles '[analysts]' and activated grants via '[analysts, readers]' is not authorized for op ADD_TABLE_SNAPSHOT
  data files on the store before 4, after 4;  PUTs under data/ during the attempt: 1
  analyst: CREATE TABLE:                                       FAILED -> … not authorized for op CREATE_TABLE_DIRECT
  analyst: rewrite_data_files on a 15-file table:              FAILED -> … not authorized for op ADD_TABLE_SNAPSHOT
           PUTs under data/ during the attempt: 3;  DELETEs: 9
  analyst: expire_snapshots:                                   FAILED -> … not authorized for op REMOVE_TABLE_SNAPSHOTS

The refusal identifies the principal, its active roles and the denied operation. That is enough to explain the catalog decision. The third line tells you what happened before that decision. The insert was refused at ADD_TABLE_SNAPSHOT, which is the commit. Before the commit, the engine had already written the data file to the store with the storage key it holds. The count did not change only because Spark cleaned up after the refusal. The compaction wrote three files and deleted nine on its way to a refused commit. The catalog authorises the commit — not the write. Everything before the commit happens under the storage plane’s rules, and on this platform those rules were “one key, everything”.

Two smaller things from the same session. The analyst read a table in another namespace without any grant on that namespace, because the role’s grants were made at the catalog level and Polaris applies them to everything beneath. A namespace-scoped grant is the same call with type: namespace, and was not measured. And a principal’s secret is returned exactly once, at creation; the root principal’s attempt to rotate it later was refused with a 403. So the platform’s secret store has to catch the credential at the moment it exists, or recreate the principal.

The storage plane ignores the catalog

The 120,150 rows in the opening came from reading the prefix directly. A storage key grants access to a prefix, and the catalog’s tables occupy prefixes. A key that can read the warehouse can read every table in it — referenced files and unreferenced alike — with no request the catalog ever sees. The unreferenced files are the surprise. The raw prefix held two rows for every row in the table: the pre-merge versions of every row chapter 12 updated, readable to anyone with the key and to no one through the catalog. The events table from chapter 11 was the same story in files rather than rows, sixty-nine objects under its data prefix against fifteen the table references, the rest left by compactions that expiry has not yet reached. Expired data is not deleted data until the orphan cleanup runs, and until then the storage plane serves it.

The same credential also allowed a write. A file planted in a table’s data/ prefix is not in the table, and the table’s readers never see it. It is one add_files away from being real, it will be counted by chapter 13’s store-minus-metadata check as an orphan, and chapter 14’s cleanup will remove it after a day. None of that is a defence — it is a description of how long the window is.

Vended credentials close it

The mechanism that makes the two planes agree is the catalog issuing the storage credential, scoped to the table, per request. Polaris’s wild_sts catalog does that against SeaweedFS’s STS, and the credential it hands out was tested against everything it was not vended for.

  vended for stage2.orders: key ASIA9c3e5641…, session token present
  vended key GetObject on its own table's data file:        OK -> 511 bytes
  vended key GetObject on its own metadata.json:            OK -> 3,029 bytes
  vended key PutObject into its own data/ prefix:           OK
  PyIceberg scan with the vended credential:                OK -> 1,001 rows
  vended key GetObject on another table's data file:        AccessDenied
  vended key PutObject into another table's prefix:         AccessDenied
  vended key ListObjectsV2 on the bucket root:              AccessDenied
  vended key ListObjectsV2 on its own prefix:               AccessDenied

The credential could read and write its own table, but the attempts against another table were denied. The listing refusal on the last line needs a separate explanation — it comes from this store’s implementation rather than the design. SeaweedFS refuses the prefix-scoped list that the session policy allows, and the engines do not need it, because a reader finds files through manifests and never lists. With vending, the storage plane enforces the catalog’s decision, because the only credential a reader holds is one the catalog cut for that table. The static key that opened this chapter is what vending replaces, and the platform that keeps both has not closed anything.

Two things chapter 3 found still hold. Vending requires the catalog to be able to reach a token service, which is a dependency the fixture does not have and chapter 16 has to replicate. And a client that asks for vended credentials from a catalog that cannot vend them gets a 400, so the request header is per catalog, not per platform.

The engine plane flattens identity

Trino’s Polaris catalog is configured once, with the root credential, and every Trino user’s queries go to the catalog as that principal.

  trino --user analyst:  INSERT INTO orders_curated …   -> INSERT: 1 row
  trino --user nobody:   INSERT INTO orders_curated …   -> INSERT: 1 row
  rows written through Trino by two different users: trino-nobody 1, trino-analyst 1

Neither user has a Polaris principal. Both wrote, as root, because the engine’s identity is the one the catalog sees. Every grant of the previous section is irrelevant to a user who arrives through this engine, and so is vending, because the credential vended to root is root’s. An engine with a fixed catalog identity is a hole in the catalog plane the size of that identity. The fix is on the engine side. Trino’s own access control in front of the catalog, or per-user catalog credentials passed through, which this version’s REST catalog connector configures per catalog and not per session. What the lab measured is the default — and the default is root.

The audit trail, plane by plane

The analyst’s one read, as each plane recorded it.

  storage (the proxy's access log):  22 GETs — 5 metadata, 17 data;  method, key, status, bytes;  no identity
  catalog (Polaris access log):      192.168.65.1 - analyst [06/Sep/2026:09:47:18 +0000] "GET /api/catalog/v1/wild_hdi/namespaces/stage15/tables/orders_curated …"

The storage plane logs what was read and not by whom — because with a static key there is no whom. The catalog side of that one session was forty-seven log lines naming the principal, including the token exchange, the namespace listing and the table load, which is the volume an audit pipeline has to expect per query per engine. The catalog plane logs the principal, the table and the operation, and it logged the refusals above with the same detail. With vending, the storage log gains an identity too, since each vended key is traceable to the load-table request that issued it. The audit that answers “who read this table” is the catalog’s, and it exists only for reads that went through the catalog, which the opening of this chapter did not.

A view is not a policy

Row and column policies are the governance question every platform review asks — and neither catalog on this platform has them. Polaris 1.7’s policies are maintenance policies, and Lakekeeper 0.13’s authorisation is per object. The nearest thing is a view with a filter in it, granted to a principal that is not granted the table.

  root: CREATE VIEW po.stage15.orders_closed AS SELECT … WHERE status = 'closed'
  analyst (TABLE_READ_DATA on the catalog): reads the view -> 1 row;  reads the base table -> 60,075 rows
  viewer (VIEW_* and list privileges only):  reads the view -> ForbiddenException: … not authorized for op LOAD_TABLE
  Trino reads the Spark-created view:        Cannot read unsupported dialect 'spark' for view 'stage15.orders_closed'

The analyst reads both, because the analyst can read the table and the view is just SQL over it. The viewer — granted the view and not the table — cannot read the view. Spark expands the view’s SQL and loads the base table under the viewer’s own identity, and the catalog refuses that load. And Trino cannot read the view at all, which chapter 5 found and which has not changed. A catalog view is a convenience for readers who already have the table, and a policy for no one. The engine expands it client-side, and the catalog sees a table load. A row policy on this platform is a second table, maintained by a job that holds the grant. That is chapter 12’s curated-table pattern used for access rather than for lineage.

Encryption, in one paragraph

Iceberg 1.11 carries table encryption with a key-management client and per-file keys, and none of it was run here. The lab has no key service, and an encryption test without one proves the wrong thing. What the chapter can say is where it sits. It is a storage-plane control that the catalog configures and the engines enforce, so it has the engine-plane problem above in a different shape. An engine that cannot reach the key service cannot read the table — and one that can reads all of it. It is flagged as docs-verified and belongs with chapter 17’s managed-service comparison, where it is usually the service’s.

The policy test suite

A policy test needs to follow the routes a user can actually take. This matrix runs every principal through every engine and operation, comparing the result with the expected permission. A mismatch produces a red row.

PrincipalEngineRead tableWrite tableRead other tableRead files directly
analyst, catalog read-only, static keySparkallowrefused at commit; file written firstallow (catalog-level grant)allow
analyst, catalog read-only, vendedSparkallowrefusedallowrefused
none, static keyDuckDB directallowallow (plant)allowallow, twice the rows
root via wild_sts, vendedPyIcebergallowallowdenieddenied
any userTrinoallow as rootallow as rootallow as rootn/a
viewer, view grants onlySparkrefused (view expands)refusedrefusedallow (static key)

The bold cells are the platform’s findings, and each one is a decision. Retire the static key in favour of vending. Put access control in front of Trino, or give it per-user credentials. Stop describing views as policies. The suite is re-run whenever a principal, a role, an engine or a catalog changes. Chapter 5’s certification harness is the right place for it, because it already runs every engine against every table.

What to decide

Vending everywhere, static keys nowhere. The storage plane enforces the catalog’s decisions only when the catalog issues the credentials. A platform with both has the weaker of the two.

Every engine carries the user’s identity or sits behind its own access control. An engine that authenticates to the catalog as itself makes every grant in the catalog a grant to everyone who can reach the engine.

Authorisation is at the commit — the write already happened. A refused writer has used storage I/O and left files that the cleanup will remove. Size the cleanup for it, and do not read a clean file count as proof nothing was attempted.

Views are not policies. A row or column restriction is a table, maintained by a principal that has the source.

Audit through the catalog, because that is where the identity is. Reads that bypass the catalog have no audit line, and with a static key in circulation there are such reads.

What was not run

Table encryption, which needs a key service. Lakekeeper’s OpenFGA authorisation model, which is per object and was not configured in this lab. Trino’s own access-control layer and per-user catalog credentials, which are the fix for the flattening and were not deployed. A cloud IAM with prefix conditions on ListBucket, which would show whether the own-prefix listing denial is SeaweedFS’s alone. And the analyst’s rewrite_data_files cleanup after the refusal was observed as nine deletes, not verified as the same nine files.

Exercises

1. Find the static keys. List every place a storage credential is configured on your platform: engine catalogs, notebooks, connectors, maintenance jobs, the counting proxy. For each, ask which tables it can read that the holder’s catalog grants do not allow.

Show answer

The list is longer than the catalog’s principal list, and every entry beyond it is a reader the catalog does not know about. The maintenance jobs are the hardest to retire, because they need every table; they are also the ones a compromised key most resembles.

2. Write through a flattened engine. With a user that has no catalog principal, insert a row through Trino and then find the write in the catalog’s audit log.

Show answer

The log records the catalog’s own principal for Trino, not the user, and the only trace of who ran the statement is in Trino’s query history. An audit that has to join two logs to name a writer is an audit with a gap the size of the join.

Final thoughts

Three planes, and a request has to pass one. The catalog’s grants held exactly where they apply — at the commit, after the write. The storage key read everything and twice over. Vending made the storage plane enforce the catalog’s decision, to the byte, and only for the readers who asked the catalog. Trino asked as root for everyone. The view expanded into the table it was meant to hide.

The next chapter is the day the catalog is gone, or the region is. What a backup of the catalog is and is not, why copying the bucket does not copy the tables, and a game day that recovers the bookshop from its metadata alone.

Next: Copying the Bucket Is Not Copying the Table

Comments