Repository navigation
fix(catalog): authorize renames and commits by the tables they change - #758
Merged
Merged
Conversation
The catalog guard authorizes POST /{prefix}/tables/rename and
/views/rename as catalog-root writes without reading the body. The spec
sets the model: the source needs what a drop needs, the destination what
a create in its namespace needs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Any role with any write or manage grant in a warehouse could rename any
table or view, including one it could not read, onto a name it held a
grant on, and then read it. The guard never read the rename body.
The mount now hands the guard the body of POST /{prefix}/tables/rename
and /views/rename and puts the same bytes back for the view hold, the
table hook and lakekeeper. The guard requires write on the source (as a
drop does) and namespace-level write on the destination (as a create
does), answers 403 naming the side that failed, and 400 for a body that
is not a rename.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /{prefix}/transactions/commit names its tables only in the body, so
the guard authorized it as a catalog-root write: any role with any write
grant could commit to any table. The mount now hands the guard the
commit body (16 MiB, the table hook's own cap) and puts it back. The
guard requires, for every table-changes identifier, what a single-table
update requires, refuses the whole commit with a 403 naming the first
table not covered, and answers 400 for a body it cannot read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…warehouse
POST /{prefix}/v1/aws/s3/sign passes the guard as a catalog-root write,
and lakekeeper's own table check runs on AllowAllAuthorizer. What stops a
role with any write grant from getting a signature for any table's files
is that egret creates every S3 warehouse with remote signing off, which
lakekeeper refuses before any table lookup. The profile is extracted into
s3_storage_profile with that reason documented, a unit test fails if
signing is turned on, and a test through the real mount pins the 403.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The table hook and view hold stubs decided by path suffix, which drifts from the mount. They now use GuardedBody::of. A duplicate-key rename body is pinned to rename nothing, whichever copy is unauthorized. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mount read a rename or multi-table commit body (up to 16 MiB) before the token was verified, so an unauthenticated caller could make it buffer. CatalogGuard gains a required authenticate step that the mount runs first. A declared Content-Length over the cap is refused unread with 413, and a streamed body that crosses the cap is a 413 too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lakekeeper commits a single-table commit to the body's identifier when it has one, including its Spark-branch fallback, so a namespace grant on corp.fin could commit to the table corp.fin. The mount now hands the guard this body too (after authN, 16 MiB), and the guard requires write on the table the commit actually reaches. View commits stay bound to their path in lakekeeper; that is pinned by a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lakekeeper resolves namespace and table names under its ICU case_insensitive collation; the guard compared bytes, so whether a grant matched depended on how the caller spelled the name. The guard now asks Postgres to compare the request's names with the grants' names under that same collation, on every route, and decodes path segments as lakekeeper does (percent-decoding, + as a space). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DELETE /namespaces/{ns}?recursive=true deletes every child namespace
and table, but the guard required write on {ns} only, so a grant on corp
could delete corp.fin. A recursive drop now needs a catalog-level write
or manage grant; the query is decoded as lakekeeper decodes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remote signing is off on egret's warehouses and unused by its clients,
and lakekeeper's table check behind the signer allows everyone. The
guard now refuses /aws/s3/sign, /{prefix}/v1/aws/s3/sign and the
table-specific signer routes outright, whatever the grants. The profile
pins stay; the lakekeeper refusal is now pinned against its own router.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lakekeeper vends read-write-delete storage credentials whenever its authorizer allows WriteData, and egret runs it on AllowAllAuthorizer, so a read-only catalog grant receives credentials that can write and delete the table's files. Recorded with the evidence for a separate package. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard and the mount answered 401/403/400/413 as plain text, which
Iceberg clients report as an unexpected payload. Every refusal is now
{"error": {"message", "type", "code"}}, as lakekeeper answers, with the
same messages.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trino writes only through single-table commits, whose bodies the guard now reads. A commit in Iceberg Java's UpdateTableRequest shape, with an identifier equal to the path, commits for single- and multi-level namespaces, a name with a space, and a grant in another case. Also records O1 (the /config uri behind a non-default edge port). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trino 483's commit bodies carry no identifier; one captured in the live check (an ADD COLUMN, uuid replaced) commits with a write grant and is refused with a read grant. The spec records both live checks and O3, the missing body-read timeout on guarded routes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
egret's embedded Iceberg REST catalog authorizes each request against
catalog.catalog_grantsin a guard in front of Lakekeeper. Several write routes were authorized by their path alone, or as catalog-root operations, without reading the body that names the tables they change. As a result, a role with any write grant in a warehouse could:transactions/commit(measured: a property set on a table the role could not write);sales.Secretreachedsales.secret;corp.fin.ledgerdeleted with a grant oncorponly).This PR makes the guard read those bodies and decide on the tables they actually change.
Spec:
docs/en/specs/2026-10-27-catalog-rename-authorizes-source-design.md(D1–D14, related defects in §7, live check and gate recorded).No migration. No API shape change.
Changes (
egret-binguard,egret-catalog-enginemount)tables/rename,views/rename). The source needs what a drop needs: a write or manage grant covering the source table. The destination needs what a create needs: acataloggrant, or anamespacegrant on the destination namespace. Atablegrant on the destination name is not enough.transactions/commit). Every table intable-changesneeds what a single-table update needs. One uncovered table refuses the whole commit, which matches Lakekeeper's all-or-nothing commit.POST …/tables/{t}). The guard authorizes the table Lakekeeper will actually commit to: the body'sidentifierif present, otherwise the path's table (D11). Spark branch commits keep working. View commits load by path and are pinned by a test.Content-Lengthover the cap gets 413 without reading the body; a streamed body that crosses the cap also gets 413.{"error":{"message","type","code"}}), so clients such as pyiceberg raise a typed error.Decisions
The user decided (2026-10-27) to fix F-A as its own PR, ahead of the KI-308/309 package, and to close the whole class of guard holes found on the way here. Controller defaults D1–D14 are in the spec, each with its reason.
Test plan
Full local gate on
167cf529(the last commit,a381fc1b, is docs only), against the real test DB with no DB skips:cargo fmt --check, clippy over the workspace with-D warnings,just test-shipping-resolutionjust test-ranger-gatescargo test --workspace --no-fail-fastsql_starrocks_live(7 tests) needs a live StarRocks, as on earlier PRs; and one knowntable_hook_dbtiming test (the 5 s plugin wait) failed under a load average of 20–36 and passed 3/3 alonegen:apitsc -b, build, all 10test:bundle*New tests are in
crates/egret-bin/src/catalog_guard_body_tests.rs, run throughmount_catalog_engine: the real guard, the view hold and the table hook, in front of Lakekeeper's real router.the_guard_decides_names_the_way_lakekeeper_resolves_them. The guard allows exactly when Lakekeeper's router finds the table.Live check
Ran twice on an own stack, not the shared demo stack, with a Trino 483 engine provisioned by egret, curl and pyiceberg 0.12. The second run was on the final code.
Trino keeps working.
identifierin the body; it never sendstransactions/commit.A restricted catalog client:
sales.Secret,secret,SECRETandse%63retpass, and an ungranted table gets 403;pyiceberg raises a clean
ForbiddenError.Unauthenticated oversized request: 401 in 3–7 ms. An authenticated one over the cap gets 413 in 5–12 ms, without reading the body.
fix(catalog): classifications follow a renamed table or column #757: a classified table and a classified column renamed through Trino still move their classification.
Follow-ups (not in this PR)
AllowAllAuthorizer./configadvertises its uri without the port behind the edge on a non-default port, which breaks pyiceberg there (likelyX-Forwarded-Host; not confirmed).Documentation
Platform docs (
egret-data/docs) follow in a separate docs PR: what a rename, a commit and a recursive drop require, and the case-insensitive grant matching.Checklist
🤖 Generated with Claude Code